diff --git a/panels_ipe/css/panels_ipe.css b/panels_ipe/css/panels_ipe.css
new file mode 100644
index 0000000..381159d
--- /dev/null
+++ b/panels_ipe/css/panels_ipe.css
@@ -0,0 +1,455 @@
+/**
+ * @file
+ * Contains all CSS for the Panels In-Place Editor.
+ */
+
+/* Define our icon font, which is generated from the SVGs in /images. */
+@font-face {
+  font-family: PanelsIPEIcon;
+  src: url(../fonts/ipeicons.woff);
+}
+
+.ipe-icon {
+  display:inline-block;
+  vertical-align: middle;
+  font-family: PanelsIPEIcon;
+  font-size: 24px;
+}
+
+.ipe-action-list .ipe-icon {
+  height: 24px;
+  margin-top: -10px;
+  display: block;
+}
+
+.ipe-icon.ipe-icon-up:before {
+  content: "\e900";
+}
+
+.ipe-icon.ipe-icon-down:before {
+  content: "\e901";
+}
+
+.ipe-icon.ipe-icon-warning:before {
+  content: "\e902";
+}
+
+.ipe-icon.ipe-icon-change_layout:before {
+  content: "\e903";
+}
+
+.ipe-icon.ipe-icon-edit:before {
+  content: "\e904";
+}
+
+.ipe-icon.ipe-icon-manage_content:before {
+  content: "\e905";
+}
+
+.ipe-icon.ipe-icon-save:before {
+  content: "\e906";
+}
+
+.ipe-icon.ipe-icon-loading:before {
+  content: "\e907";
+  animation: ipe-spin 1s infinite linear;
+}
+
+.ipe-icon.ipe-icon-remove:before {
+  content: "\e908";
+}
+
+.ipe-icon.ipe-icon-configure:before {
+  content: "\e909";
+}
+
+.ipe-icon.ipe-icon-configure {
+  font-size: 20px;
+}
+
+@keyframes ipe-spin {
+  from {transform:rotate(360deg);}
+  to {transform:rotate(0deg);}
+}
+
+/* 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;
+}
+
+/* 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;
+}
+
+/* Remove list styling from the output of the TabsView. */
+.ipe-tabs {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+/* Display tabs inline and slightly on top of .ipe-tabs-content. */
+.ipe-tab {
+  overflow: hidden;
+  display: inline-block;
+  vertical-align: bottom;
+  padding: 10px 5px 10px 5px;
+  background-color: white;
+  border-top: 1px solid darkgray;
+  margin-bottom: -1px;
+}
+
+.ipe-tab:first-child {
+  border-left: 1px solid darkgray;
+  border-top-left-radius: 5px;
+}
+
+.ipe-tab:last-child {
+  border-right: 1px solid darkgray;
+  border-top-right-radius: 5px;
+}
+
+.ipe-tab.active a {
+  color: darkgray;
+}
+
+.ipe-tab a {
+  color: black;
+  height: 30px;
+  display: block;
+  text-transform: uppercase;
+  vertical-align: top;
+  border: none;
+  cursor: pointer;
+  transition: .2s;
+}
+
+.ipe-tab a:hover {
+  color: darkgray;
+  border: none;
+}
+
+.ipe-tab a::selection {
+  background: none;
+}
+
+/* Provide default styles and a minimum height for tab content. */
+.ipe-tab-content {
+  display: none;
+  min-height: 100px;
+  padding: 5px 5px 10px 5px;
+  background-color: white;
+  border-top: 1px solid darkgray;
+}
+
+.ipe-tab-content.active {
+  display: block;
+}
+
+/* Don't show text for these tabs */
+[data-tab-id="save"], [data-tab-id="edit"] {
+  overflow: hidden;
+  width: 24px;
+}
+
+/* Styles for the Layout selector. */
+.ipe-current-layout, .ipe-all-layouts {
+  display: inline-block;
+}
+
+.ipe-current-layout {
+  vertical-align: top;
+  padding-right: 10px;
+}
+
+/* Remove <ul> list styling and make list scrollable. */
+.ipe-layouts {
+  vertical-align: top;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  white-space: nowrap;
+  overflow-x: auto;
+}
+
+/* Show layouts as clickable things. */
+.ipe-layout {
+  cursor: pointer;
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.ipe-layout-title {
+  font-weight: bold;
+  text-transform: uppercase;
+}
+
+.ipe-layout-title a {
+
+}
+
+/* Hide the tray for the edit and save tabs. */
+[data-tab-content-id="edit"].active, [data-tab-content-id="save"].active {
+  display: none;
+}
+
+/* Style the block/region headers. */
+div.ipe-actions {
+  display: block;
+  height: 20px;
+  border-radius: 5px 5px 0 0;
+  background-color: rgb(222, 222, 222);
+  padding: 5px;
+  margin-top: 1px;
+  clear: both;
+}
+
+/* Indicate that blocks are draggable */
+div.ipe-actions-block {
+  cursor: move;
+  background-color: rgb(243, 243, 243);
+}
+
+.ipe-actions ul.ipe-action-list {
+  float: right;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.ipe-actions h5, .ipe-actions li {
+  font-family: Arial, Helvetica, sans-serif;
+  font-size: 12px;
+  text-transform: uppercase;
+  font-weight: bold;
+  margin: 0;
+}
+
+.ipe-actions h5 {
+  float: left;
+}
+
+.ipe-actions a {
+  color: black;
+  display: block;
+  text-transform: uppercase;
+  border: none;
+  cursor: pointer;
+  transition: .2s;
+}
+
+.ipe-actions a:hover {
+  color: #bebebe;
+  border: none;
+}
+
+.ipe-actions *::selection {
+  background: none;
+}
+
+.ipe-action-list li {
+  display: inline-block;
+  vertical-align: middle;
+}
+
+.ipe-action-list [data-action-id="move"] select {
+  background: transparent;
+  border: none;
+  text-transform: uppercase;
+}
+
+/* This is used for highlighting new content on screen. */
+.ipe-highlight {
+  animation: ipe-blink .4s ease-in-out 2;
+}
+
+@keyframes ipe-blink {
+  from, to {
+    box-shadow: 0 0 0 1px transparent;
+  }
+  50% {
+    box-shadow: 0 0 0 2px rgba(88, 160, 44, 0.70);
+  }
+}
+
+/* Drag/drop styles for blocks. */
+.ipe-droppable {
+  display: none;
+  width: 100%;
+  height: 30px;
+  margin: 5px 0 5px;
+  background-color: transparent;
+  border: 1px dashed #3c3c3c;
+  transition: .2s;
+}
+
+.ipe-droppable.active {
+  display: block;
+}
+
+.ipe-droppable.hover {
+  background-color: rgba(88, 160, 44, 0.70);
+  border-color: rgb(67, 125, 33);
+}
+
+/* Style the BlockPicker. */
+.ipe-block-picker-bottom {
+  overflow-x: scroll;
+  white-space: nowrap;
+  margin: auto;
+  padding: 20px;
+  min-height: 70px;
+}
+
+.ipe-block-picker-top {
+  display: none;
+}
+
+.ipe-block-picker-top.active {
+  display: block;
+  padding: 20px;
+  max-height: 100%;
+  border-bottom: 1px solid darkgray;
+}
+
+.ipe-block-category {
+  color: black;
+  display: inline-block;
+  padding: 10px;
+  margin-right: 10px;
+  text-transform: uppercase;
+  font-size: 15px;
+  font-weight: bold;
+  border: 1px solid transparent;
+  border-radius: 5px;
+  transition: .2s;
+  cursor: pointer;
+}
+
+.ipe-block-category:hover,.ipe-block-category.active {
+  border-color: rgb(67, 125, 33);
+  color: inherit;
+}
+
+.ipe-block-category-count {
+  color: white;
+  background: black;
+  border-radius: 50%;
+  margin-left: 5px;
+  font-size: 10px;
+  height: 20px;
+  width: 20px;
+  line-height: 20px;
+  display: inline-block;
+  vertical-align: middle;
+}
+
+.ipe-block-plugin {
+  text-transform: uppercase;
+  display: inline-block;
+  margin: 5px 20px 0 0;
+  text-align: left;
+  position: relative;
+}
+
+.ipe-block-plugin-info {
+  display: inline-block;
+}
+
+.ipe-block-plugin h5 {
+  font-size: 14px;
+  font-weight: bold;
+  margin: 0;
+}
+
+.ipe-block-plugin p {
+  font-size: 12px;
+}
+
+/* The "Add" button for Block Plugins. */
+.ipe-block-plugin a {
+  display: inline-block;
+  vertical-align: top;
+  font-size: 12px;
+  padding: 0 5px 0 5px;
+  border: 1px solid darkgray;
+  border-radius: 5px;
+  transition: .2s;
+  color: inherit;
+  cursor: pointer;
+}
+
+.ipe-block-plugin a:hover {
+  border-color: rgb(67, 125, 33);
+  color: inherit;
+}
+
+/* Theme the block plugin form */
+
+.ipe-block-picker-top h4 {
+  font-size: 16px;
+  text-transform: uppercase;
+  border-bottom: 1px solid darkgray;
+  padding: 5px;
+  margin: 0 0 10px 0;
+}
+
+.ipe-block-plugin-form {
+  text-align: left;
+  margin: 0 auto;
+  display: inline-block;
+}
+
+#panels-ipe-block-plugin-form-wrapper .form-item {
+  margin: 0 0 10px 0;
+}
+
+#panels-ipe-block-plugin-form-wrapper label {
+  display: inline-block;
+  text-transform: uppercase;
+  margin-right: 5px;
+}
+
+#panels-ipe-block-plugin-form-wrapper summary {
+  text-transform: uppercase;
+  background: transparent;
+}
+
+#panels-ipe-block-plugin-form-wrapper .ipe-icon-loading {
+  text-align: center;
+  width: 100%;
+}
+
+#panels-ipe-block-plugin-form-wrapper input[type="submit"] {
+  text-transform: uppercase;
+  margin: 0;
+  background: white;
+  color: black;
+  border-radius: 5px;
+  display: inline-block;
+  transition: .2s;
+}
+
+#panels-ipe-block-plugin-form-wrapper input[type="submit"]:hover {
+  border-color: rgb(67, 125, 33);
+  color: black;
+  background: white;
+}
+
+#panels-ipe-block-plugin-form-wrapper {
+  max-width: 650px;
+  padding-right: 10px;
+}
+
+#panels-ipe-block-plugin-form-preview {
+  vertical-align: top;
+  margin-top: 10px;
+  border: 1px dashed darkgray;
+  padding: 5px;
+}
diff --git a/panels_ipe/fonts/ipeicons.woff b/panels_ipe/fonts/ipeicons.woff
new file mode 100755
index 0000000000000000000000000000000000000000..9374da706b1b3a3f81b7ae6e39db748b10bc0650
GIT binary patch
literal 2092
zcma)7Uu;uV82`R=+TM2S+ST>8g3D;vxlL+1Xm?wUW9$J-Z~_s%B|)Z?wQjJjg>5J#
zJk-Saz>6eg5{wT#@YI_aAAIzIggu%Vi6uS~^+AF!Cd!Mp^>^;Q3uqSQwBPxD-|u|q
z`*UvZ=?6!PMPkD6zev4GYxgm2JC43NktJe37EdV&KjZVvuawV$TZH|Vl6dLv)rIn!
z;=YEhe3kSsEMIyL+$NFyRmuBDkKJ9YmMcWwFR`9iqCI-mS_BF17uX|8wCnQQ#g+Ao
zM7|*Gmy~44`}1;r9)7+9u!od*$L04c<%`JcLrhB8GC=Rt$}3fHA0hY0N^ZOs`14$S
zZ5?H)c#fY0lJYygN?syyLwp1K6G!ty%5ebplY{%8v=p8pdJvGeySHgZLclO&Yb0!7
zX8{_-n!<yamDeD7?7n(%QUV{Q#DhS$-R;`D5)a76-2Ex;ad+4Yn3xz|VLOV$38KQw
zcIwCZ|A#}62}xo}^&a4OAzl#FrK!!$DX|27=gt%{e02D{p?p{y(*i6u;Q$&WJi1N^
zlG5nEC6`dApZZWsGQiKLjGS($DJ`3wbSEvSgfS!2f3+H>X*HYZzkz)XIRg}+5DgFw
zBqPZPzRAc7jA;i0HrVV~wm7r0HrVPYjgl3TO4~;v?3n9v5;fy-74#gBG3O^Icyu_7
zHXAqjhnt;o@p~{>=}QHV+&psMVfd?Hs;?4Mj0z;l>^?+o8>nn2?Lz;_VV@q!MUqC`
zM$uR<navxK$<dTuudq5ak}V;BXLl-_P4PW7cXsnVxsgq^En%9?>`2>EtT0EiZBv+S
z3wgHAAVkkmVk^&9^f5U+YE0&H*_a;nr3R8lQWvcb*+ELFtkp7RXALEdj>(psL8hj?
zNLJf)R*YG;EKBty$U;vhdeZU4hz%`nWQPPIYw>9|CNm-x8O{jgD~yF$j}D2PUKrD{
z%`)bS<Kgi1jzMGY^1*)?mL>H@(`t+t=ZwU@o%_d6W)EJTGuqa_2<3wsm|DCc(|Er;
zs#o+Q)9t1x@kXiS)L0>1evaSL5qcXQs;<$o!Zc5ChQko0(>&}8a|l1lQD|Rm7qA|U
z#kCOc;vwD-QTi3m=Jz8)As&Oyk4~`n$<vhkSO`AhlOEF(*3L#oqWv87H~m5GkB&sn
zYGLrwcjBp>I(MdzAD>pjpZ4gzy?Rg48}?t*ydvGF`_F5=z1n$yESMHv?V3OAEk2FU
zk`r+HxH=d2y?*_(-{iaDsXwR}$A@wE!^ZW;8c(-{r>pK>y#4mTKKp)B$BiLdTaj#2
zO(AE+vDG#YTpXb^)*7yuuC}0Ckcz#dhoZQN_s{|SH(a4F=tq8*Pw-iOgjH2@h)yDy
z8e0|FhC79@!|9Ak1Lt4?e9FZVIPYQ)@C6rp@vo)3*av>r#XWdiuc-Ud695y!ih~94
zf{P{aJ{Nm{FT2=FF-o}D2mY9gduWnwotdw%)ax}mL-SOp6-b?GrRwV1nR+d;H?xOI
zRHao~g98>2XD?-F&$hd^C684XE-aT<k+h0JFVHfTX?0uAV!gJWSg6*jtL61-C2{&v
t;?R8kFlx(C(ayb&><b7}vnwjoI&=lxX}W~@klnyxrvcQc?z;aRe*+9&SnL1*

literal 0
HcmV?d00001

diff --git a/panels_ipe/fonts/selection.json b/panels_ipe/fonts/selection.json
new file mode 100755
index 0000000..582db98
--- /dev/null
+++ b/panels_ipe/fonts/selection.json
@@ -0,0 +1,252 @@
+{
+	"IcoMoonType": "selection",
+	"icons": [
+		{
+			"icon": {
+				"paths": [
+					"M810.667 554.667h-597.333v-85.333h597.333v85.333z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"grid": 0,
+				"tags": [
+					"ic_remove_black_24px"
+				]
+			},
+			"attrs": [],
+			"properties": {
+				"order": 6,
+				"id": 8,
+				"prevSize": 32,
+				"code": 59656,
+				"name": "ic_remove_black_24px"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 0
+		},
+		{
+			"icon": {
+				"paths": [
+					"M512 170.667v-128l-170.667 170.667 170.667 170.667v-128c141.227 0 256 114.773 256 256 0 43.093-10.667 84.053-29.867 119.467l62.293 62.293c33.28-52.48 52.907-114.773 52.907-181.76 0-188.587-152.747-341.333-341.333-341.333zM512 768c-141.227 0-256-114.773-256-256 0-43.093 10.667-84.053 29.867-119.467l-62.293-62.293c-33.28 52.48-52.907 114.773-52.907 181.76 0 188.587 152.747 341.333 341.333 341.333v128l170.667-170.667-170.667-170.667v128z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"ic_sync_black_24px"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 10,
+				"id": 7,
+				"prevSize": 32,
+				"code": 59655,
+				"name": "ic_sync_black_24px"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 1
+		},
+		{
+			"icon": {
+				"paths": [
+					"M512 341.333l-256 256 60.16 60.16 195.84-195.413 195.84 195.413 60.16-60.16z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"ic_expand_less_black_24px"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 9,
+				"id": 6,
+				"prevSize": 32,
+				"code": 59648,
+				"name": "ic_expand_less_black_24px"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 2
+		},
+		{
+			"icon": {
+				"paths": [
+					"M707.84 366.507l-195.84 195.413-195.84-195.413-60.16 60.16 256 256 256-256z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"ic_expand_more_black_24px"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 6,
+				"id": 5,
+				"prevSize": 32,
+				"code": 59649,
+				"name": "ic_expand_more_black_24px"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 3
+		},
+		{
+			"icon": {
+				"paths": [
+					"M42.667 896h938.667l-469.333-810.667-469.333 810.667zM554.667 768h-85.333v-85.333h85.333v85.333zM554.667 597.333h-85.333v-170.667h85.333v170.667z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"ic_warning_black_24px"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 5,
+				"id": 4,
+				"prevSize": 32,
+				"code": 59650,
+				"name": "ic_warning_black_24px"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 4
+		},
+		{
+			"icon": {
+				"paths": [
+					"M128 554.667h341.333v-426.667h-341.333v426.667zM128 896h341.333v-256h-341.333v256zM554.667 896h341.333v-426.667h-341.333v426.667zM554.667 128v256h341.333v-256h-341.333z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"tab_change_layout"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 4,
+				"id": 3,
+				"prevSize": 32,
+				"code": 59651,
+				"name": "tab_change_layout"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 5
+		},
+		{
+			"icon": {
+				"paths": [
+					"M128 736v160h160l471.893-471.893-160-160-471.893 471.893zM883.627 300.373c16.64-16.64 16.64-43.52 0-60.16l-99.84-99.84c-16.64-16.64-43.52-16.64-60.16 0l-78.080 78.080 160 160 78.080-78.080z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"tab_edit"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 3,
+				"id": 2,
+				"prevSize": 32,
+				"code": 59652,
+				"name": "tab_edit"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 6
+		},
+		{
+			"icon": {
+				"paths": [
+					"M682.667 42.667h-512c-46.933 0-85.333 38.4-85.333 85.333v597.333h85.333v-597.333h512v-85.333zM810.667 213.333h-469.333c-46.933 0-85.333 38.4-85.333 85.333v597.333c0 46.933 38.4 85.333 85.333 85.333h469.333c46.933 0 85.333-38.4 85.333-85.333v-597.333c0-46.933-38.4-85.333-85.333-85.333zM810.667 896h-469.333v-597.333h469.333v597.333z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"tab_manage_content"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 4,
+				"id": 1,
+				"prevSize": 32,
+				"code": 59653,
+				"name": "tab_manage_content"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 7
+		},
+		{
+			"icon": {
+				"paths": [
+					"M725.333 128h-512c-47.36 0-85.333 38.4-85.333 85.333v597.333c0 46.933 37.973 85.333 85.333 85.333h597.333c46.933 0 85.333-38.4 85.333-85.333v-512l-170.667-170.667zM512 810.667c-70.827 0-128-57.173-128-128s57.173-128 128-128 128 57.173 128 128-57.173 128-128 128zM640 384h-426.667v-170.667h426.667v170.667z"
+				],
+				"attrs": [],
+				"isMulticolor": false,
+				"tags": [
+					"tab_save"
+				],
+				"grid": 0
+			},
+			"attrs": [],
+			"properties": {
+				"order": 1,
+				"id": 0,
+				"prevSize": 32,
+				"code": 59654,
+				"name": "tab_save"
+			},
+			"setIdx": 0,
+			"setId": 3,
+			"iconIdx": 8
+		}
+	],
+	"height": 1024,
+	"metadata": {
+		"name": "icomoon"
+	},
+	"preferences": {
+		"showGlyphs": true,
+		"showQuickUse": true,
+		"showQuickUse2": true,
+		"showSVGs": true,
+		"fontPref": {
+			"prefix": "icon-",
+			"metadata": {
+				"fontFamily": "icomoon"
+			},
+			"metrics": {
+				"emSize": 1024,
+				"baseline": 6.25,
+				"whitespace": 50
+			},
+			"embed": false
+		},
+		"imagePref": {
+			"prefix": "icon-",
+			"png": true,
+			"useClassSelector": true,
+			"color": 4473924,
+			"bgColor": 16777215
+		},
+		"historySize": 100,
+		"showCodes": true
+	}
+}
\ No newline at end of file
diff --git a/panels_ipe/images/block_down.svg b/panels_ipe/images/block_down.svg
new file mode 100644
index 0000000..2e5c41c
--- /dev/null
+++ b/panels_ipe/images/block_down.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/panels_ipe/images/block_up.svg b/panels_ipe/images/block_up.svg
new file mode 100644
index 0000000..32f02d9
--- /dev/null
+++ b/panels_ipe/images/block_up.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/panels_ipe/images/loading.svg b/panels_ipe/images/loading.svg
new file mode 100644
index 0000000..294f735
--- /dev/null
+++ b/panels_ipe/images/loading.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/panels_ipe/images/tab_change_layout.svg b/panels_ipe/images/tab_change_layout.svg
new file mode 100644
index 0000000..cbae5ae
--- /dev/null
+++ b/panels_ipe/images/tab_change_layout.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
+</svg>
\ No newline at end of file
diff --git a/panels_ipe/images/tab_edit.svg b/panels_ipe/images/tab_edit.svg
new file mode 100644
index 0000000..4488460
--- /dev/null
+++ b/panels_ipe/images/tab_edit.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/panels_ipe/images/tab_manage_content.svg b/panels_ipe/images/tab_manage_content.svg
new file mode 100644
index 0000000..7ee7f4c
--- /dev/null
+++ b/panels_ipe/images/tab_manage_content.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
+</svg>
\ No newline at end of file
diff --git a/panels_ipe/images/tab_save.svg b/panels_ipe/images/tab_save.svg
new file mode 100644
index 0000000..f473722
--- /dev/null
+++ b/panels_ipe/images/tab_save.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
+</svg>
\ No newline at end of file
diff --git a/panels_ipe/images/warning.svg b/panels_ipe/images/warning.svg
new file mode 100644
index 0000000..576964c
--- /dev/null
+++ b/panels_ipe/images/warning.svg
@@ -0,0 +1,4 @@
+<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
+</svg>
\ No newline at end of file
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..157ea59
--- /dev/null
+++ b/panels_ipe/js/models/BlockModel.js
@@ -0,0 +1,121 @@
+/**
+ * @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,
+
+      /**
+       * Whether of not this Block is new.
+       *
+       * @type {bool}
+       */
+      new: 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 provider for the block (usually the module name).
+       *
+       * @type {string}
+       */
+      provider: 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
+
+    }
+
+  });
+
+  /**
+   * @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,
+
+    /**
+     * For Blocks, our identifier is the UUID, not the ID.
+     *
+     * @type {function}
+     *
+     * @param {Object} attrs
+     *   The attributes of the current model in the collection.
+     *
+     * @return {string}
+     *   The value of a BlockModel's UUID.
+     */
+    modelId: function (attrs) {
+      return attrs.uuid;
+    },
+
+    /**
+     * Moves a Block up or down in this collection.
+     *
+     * @type {function}
+     *
+     * @param {Drupal.panels_ipe.BlockModel} block
+     *  The BlockModel you want to move.
+     * @param {string} direction
+     *  The string name of the direction (either "up" or "down").
+     */
+    shift: function (block, direction) {
+      var index = this.indexOf(block);
+      if ((direction === 'up' && index > 0) || (direction === 'down' && index < this.models.length)) {
+        this.remove(block, {silent: true});
+        var new_index = direction === 'up' ? index - 1 : index + 1;
+        this.add(block, {at: new_index, silent: true});
+      }
+    }
+
+  });
+
+}(_, jQuery, Backbone, Drupal, drupalSettings));
diff --git a/panels_ipe/js/models/BlockPluginModel.js b/panels_ipe/js/models/BlockPluginModel.js
new file mode 100644
index 0000000..51f01ba
--- /dev/null
+++ b/panels_ipe/js/models/BlockPluginModel.js
@@ -0,0 +1,104 @@
+/**
+ * @file
+ * Base Backbone model for a Block Plugin.
+ */
+
+(function (_, $, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.BlockPluginModel = Backbone.Model.extend(/** @lends Drupal.panels_ipe.BlockPluginModel# */{
+
+    /**
+     * @type {object}
+     */
+    defaults: /** @lends Drupal.panels_ipe.BlockPluginModel# */{
+
+      /**
+       * The plugin ID.
+       *
+       * @type {string}
+       */
+      plugin_id: null,
+
+      /**
+       * The block's id (machine name).
+       *
+       * @type {string}
+       */
+      id: null,
+
+      /**
+       * The plugin label.
+       *
+       * @type {string}
+       */
+      label: null,
+
+      /**
+       * The category of the plugin.
+       *
+       * @type {string}
+       */
+      category: null,
+
+      /**
+       * The provider for the block (usually the module name).
+       *
+       * @type {string}
+       */
+      provider: null
+
+    },
+
+    idAttribute: 'plugin_id'
+
+  });
+
+  /**
+   * @constructor
+   *
+   * @augments Backbone.Collection
+   */
+  Drupal.panels_ipe.BlockPluginCollection = Backbone.Collection.extend(/** @lends Drupal.panels_ipe.BlockPluginCollection# */{
+
+    /**
+     * @type {Drupal.panels_ipe.BlockPluginModel}
+     */
+    model: Drupal.panels_ipe.BlockPluginModel,
+
+    /**
+     * Defines a sort parameter for the collection.
+     *
+     * @type {string}
+     */
+    comparator: 'category',
+
+    /**
+     * For Block Plugins, our identifier is the plugin id.
+     *
+     * @type {function}
+     *
+     * @param {Object} attrs
+     *   The attributes of the current model in the collection.
+     *
+     * @return {string}
+     *   A string representing a BlockPlugin's id.
+     */
+    modelId: function (attrs) {
+      return attrs.plugin_id;
+    },
+
+    /**
+     * @type {function}
+     *
+     * @return {string}
+     *   The URL required to sync this collection with the server.
+     */
+    url: function () {
+      return Drupal.panels_ipe.urlRoot(drupalSettings) + '/block_plugins';
+    }
+
+  });
+
+}(_, jQuery, Backbone, Drupal));
diff --git a/panels_ipe/js/models/LayoutModel.js b/panels_ipe/js/models/LayoutModel.js
new file mode 100644
index 0000000..fec7993
--- /dev/null
+++ b/panels_ipe/js/models/LayoutModel.js
@@ -0,0 +1,148 @@
+/**
+ * @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,
+
+      /**
+       * Whether or not this was the original layout for the variant.
+       *
+       * @type {bool}
+       */
+      original: false,
+
+      /**
+       * 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,
+
+      /**
+       * An array of Block UUIDs that we need to delete.
+       *
+       * @type {Array}
+       */
+      deletedBlocks: [],
+
+      /**
+       * An object mapping previous Block UUIDs to new Block UUIDs.
+       *
+       * @type {Object}
+       */
+      newBlocks: {}
+
+
+    },
+
+    /**
+     * Overrides the isNew method to mark if this is the initial layout or not.
+     *
+     * @return {bool}
+     *   A boolean which determines if this Block was on the page on load.
+     */
+    isNew: function () {
+      return !this.get('original');
+    },
+
+    /**
+     * Overrides the parse method to set our regionCollection dynamically.
+     *
+     * @param {Object} resp
+     *   The decoded JSON response from the backend server.
+     * @param {Object} options
+     *   Additional options passed to parse.
+     *
+     * @return {Object}
+     *   An object representing a LayoutModel's attributes.
+     */
+    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) {
+          if (resp.regions.hasOwnProperty(i)) {
+            var region = new Drupal.panels_ipe.RegionModel(resp.regions[i]);
+            region.set({blockCollection: new Drupal.panels_ipe.BlockCollection()});
+            resp.regionCollection.add(region);
+          }
+        }
+      }
+      return resp;
+    },
+
+    /**
+     * @type {function}
+     *
+     * @return {string}
+     *   A URL that can be used to refresh this Layout's attributes.
+     */
+    url: function () {
+      return Drupal.panels_ipe.urlRoot(drupalSettings) + '/layouts/' + this.get('id');
+    }
+
+  });
+
+  /**
+   * @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}
+     *
+     * @return {string}
+     *   A URL that can be used to refresh this collection's child models.
+     */
+    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..b5333a5
--- /dev/null
+++ b/panels_ipe/js/models/RegionModel.js
@@ -0,0 +1,75 @@
+/**
+ * @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,
+
+    /**
+     * For Regions, our identifier is the region name.
+     *
+     * @type {function}
+     *
+     * @param {Object} attrs
+     *   The current RegionModel's attributes.
+     *
+     * @return {string}
+     *   The current RegionModel's name attribute.
+     */
+    modelId: function (attrs) {
+      return attrs.name;
+    }
+
+  });
+
+}(_, 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..ae8bd9b
--- /dev/null
+++ b/panels_ipe/js/models/TabModel.js
@@ -0,0 +1,71 @@
+/**
+ * @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,
+
+      /**
+       * Whether or not the tab is loading.
+       *
+       * @type {bool}
+       */
+      loading: 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
new file mode 100644
index 0000000..17b20f2
--- /dev/null
+++ b/panels_ipe/js/panels_ipe.js
@@ -0,0 +1,132 @@
+/**
+ * @file
+ * Attaches behavior for the Panels IPE module.
+ *
+ */
+
+(function ($, _, Backbone, Drupal) {
+
+  'use strict';
+
+  /**
+   * Contains initial Backbone initialization for the IPE.
+   *
+   * @type {Drupal~behavior}
+   */
+  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]);
+
+      // If new block plugin JSON is present, it means we need to add a
+      // new BlockModel somewhere. Inform the App that this has occurred.
+      var json_wrapper = $('#panels-ipe-block-plugin-form-json', context);
+      if (json_wrapper.length > 0) {
+        // Decode the JSON
+        var data = JSON.parse(json_wrapper.html());
+        // Create a BlockModel.
+        var block = new Drupal.panels_ipe.BlockModel(data);
+        // Trigger the event.
+        Drupal.panels_ipe.app.trigger('addBlockPlugin', block, data.region);
+        // Remove the wrapper.
+        json_wrapper.remove();
+      }
+    }
+  };
+
+  /**
+   * @namespace
+   */
+  Drupal.panels_ipe = {};
+
+  /**
+   * Setups up our initial Collection and Views based on the current settings.
+   *
+   * @param {Object} settings
+   *   The contextual drupalSettings.
+   */
+  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
+    });
+
+    // Set up our initial tab views.
+    var tab_views = {
+      change_layout: new Drupal.panels_ipe.LayoutPicker(),
+      manage_content: new Drupal.panels_ipe.BlockPicker()
+    };
+
+    // 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) {
+      if (settings.panels_ipe.regions.hasOwnProperty(i)) {
+        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) {
+          if (settings.panels_ipe.regions[i].blocks.hasOwnProperty(j)) {
+            // 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);
+          }
+        }
+
+        region.set({blockCollection: block_collection});
+
+        region_collection.add(region);
+      }
+    }
+
+    // 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'
+    });
+    layout_view.render();
+
+    Drupal.panels_ipe.app.set({layout: layout});
+    app_view.layoutView = layout_view;
+  };
+
+  /**
+   * Returns the urlRoot for all callbacks
+   *
+   * @param {Object} settings
+   *   The contextual drupalSettings.
+   *
+   * @return {string}
+   *   A base path for most other URL callbacks in this App.
+   */
+  Drupal.panels_ipe.urlRoot = function (settings) {
+    return '/admin/panels_ipe/variant/' + settings.panels_ipe.display_variant.id;
+  };
+
+}(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..0408506
--- /dev/null
+++ b/panels_ipe/js/views/AppView.js
@@ -0,0 +1,219 @@
+/**
+ * @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 global events throughout the app.
+      this.listenTo(this.model, 'changeLayout', this.changeLayout);
+      this.listenTo(this.model, 'addBlockPlugin', this.addBlockPlugin);
+      this.listenTo(this.model, 'configureBlock', this.configureBlock);
+
+      // Listen to tabs that don't have associated BackboneViews.
+      this.listenTo(this.model.get('editTab'), 'change:active', this.clickEditTab);
+      this.listenTo(this.model.get('saveTab'), 'change:active', this.clickSaveTab);
+    },
+
+    /**
+     * Appends the IPE tray to the bottom of the screen.
+     *
+     * @return {Drupal.panels_ipe.AppView}
+     *   Returns this, for chaining.
+     */
+    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.
+     *
+     * @param {Array} args
+     *   An array of event arguments.
+     */
+    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});
+
+        // Change the LayoutView's layout.
+        self.layoutView.changeLayout(layout);
+
+        // 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();
+      }
+    },
+
+    /**
+     * Saves our layout to the server.
+     */
+    clickSaveTab: function () {
+      if (this.model.get('saveTab').get('active')) {
+        // Save the Layout and disable the tab.
+        var self = this;
+        self.model.get('saveTab').set({loading: true});
+        this.model.get('layout').save().done(function () {
+          self.model.get('saveTab').set({loading: false, active: false});
+          self.tabsView.render();
+        });
+      }
+    },
+
+    /**
+     * Adds a new BlockPlugin to the screen.
+     *
+     * @param {Drupal.panels_ipe.BlockModel} block
+     *   The new BlockModel
+     * @param {string} region
+     *   The region the block should be placed in.
+     */
+    addBlockPlugin: function (block, region) {
+      this.layoutView.addBlock(block, region);
+
+      // Mark all tabs as inactive and close the view.
+      this.tabsView.collection.each(function (tab) {
+        tab.set('active', false);
+      });
+
+      this.tabsView.closeTabContent();
+    },
+
+    /**
+     * Opens the Manage Content tray when configuring an existing Block.
+     *
+     * @param {Drupal.panels_ipe.BlockModel} block
+     *   The Block that needs to have its form opened.
+     */
+    configureBlock: function (block) {
+      this.tabsView.tabViews['manage_content'].activeCategory = 'On Screen';
+      this.tabsView.tabViews['manage_content'].autoClick = '[data-existing-block-id=' + block.get('uuid') + ']';
+      this.tabsView.switchTab('manage_content');
+    }
+
+  });
+
+}(jQuery, _, Backbone, Drupal));
diff --git a/panels_ipe/js/views/BlockPicker.js b/panels_ipe/js/views/BlockPicker.js
new file mode 100644
index 0000000..b383912
--- /dev/null
+++ b/panels_ipe/js/views/BlockPicker.js
@@ -0,0 +1,314 @@
+/**
+ * @file
+ * Renders a list of existing Blocks for selection.
+ *
+ * see Drupal.panels_ipe.BlockPluginCollection
+ *
+ */
+
+(function ($, _, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.BlockPicker = Backbone.View.extend(/** @lends Drupal.panels_ipe.BlockPicker# */{
+
+    /**
+     * @type {Drupal.panels_ipe.BlockPluginCollection}
+     */
+    collection: null,
+
+    /**
+     * The name of the currently selected category.
+     *
+     * @type {string}
+     */
+    activeCategory: null,
+
+    /**
+     * A Block Plugin selector to automatically click on render.
+     *
+     * @type {string}
+     */
+    autoClick: null,
+
+    /**
+     * @type {function}
+     */
+    template: _.template(
+      '<div class="ipe-block-picker-top"></div><div class="ipe-block-picker-bottom"><div class="ipe-block-categories"></div></div>'
+    ),
+
+    /**
+     * @type {function}
+     */
+    template_category: _.template(
+      '<a class="ipe-block-category<% if (active) { %> active<% } %>" data-block-category="<%= name %>">' +
+      '  <%= name %>' +
+      '  <div class="ipe-block-category-count"><%= count %></div>' +
+      '</a>'
+    ),
+
+    /**
+     * @type {function}
+     */
+    template_plugin: _.template(
+      '<div class="ipe-block-plugin">' +
+      '  <div class="ipe-block-plugin-info">' +
+      '    <h5><%= label %></h5>' +
+      '    <p>Provider: <strong><%= provider %></strong></p>' +
+      '  </div>' +
+      '  <a data-plugin-id="<%= plugin_id %>">Add</a>' +
+      '</div>'
+    ),
+
+    /**
+     * @type {function}
+     */
+    template_existing: _.template(
+      '<div class="ipe-block-plugin">' +
+      '  <div class="ipe-block-plugin-info">' +
+      '    <h5><%= block.label %></h5>' +
+      '    <p>Provider: <strong><%= block.provider %></strong></p>' +
+      '  </div>' +
+      '  <a data-existing-region-name="<%= region.name %>" data-existing-block-id="<%= block.uuid %>">Configure</a>' +
+      '</div>'
+    ),
+
+    /**
+     * @type {function}
+     */
+    template_plugin_form: _.template(
+      '<h4>Configure <strong><%= label %></strong> block</h4>' +
+      '<div class="ipe-block-plugin-form"><div class="ipe-icon ipe-icon-loading"></div></div>'
+    ),
+
+    /**
+     * @type {function}
+     */
+    template_loading: _.template(
+      '<span class="ipe-icon ipe-icon-loading"></span>'
+    ),
+
+    /**
+     * @type {object}
+     */
+    events: {
+      'click [data-block-category]': 'toggleCategory',
+      'click .ipe-block-plugin [data-plugin-id]': 'displayBlockPluginForm',
+      'click .ipe-block-plugin [data-existing-block-id]': 'displayBlockPluginForm'
+    },
+
+    /**
+     * @constructs
+     *
+     * @augments Backbone.View
+     *
+     * @param {Object} options
+     *   An object containing the following keys:
+     * @param {Drupal.panels_ipe.BlockPluginCollection} options.collection
+     *   An optional initial collection.
+     */
+    initialize: function (options) {
+      if (options && options.collection) {
+        this.collection = options.collection;
+      }
+    },
+
+    /**
+     * Renders the selection menu for picking Blocks.
+     */
+    render: function () {
+      // Initialize our BlockPluginCollection if it doesn't already exist.
+      if (!this.collection) {
+        // Indicate an AJAX request.
+        this.$el.html(this.template_loading());
+
+        // Fetch a collection of block plugins from the server.
+        this.collection = new Drupal.panels_ipe.BlockPluginCollection();
+        var self = this;
+        this.collection.fetch().done(function () {
+          // We have a collection now, re-render ourselves.
+          self.render();
+        });
+        return;
+      }
+
+      // Empty ourselves.
+      this.$el.html(this.template());
+
+      // Get a list of categories from the collection.
+      var categories_count = {};
+      this.collection.each(function (block_plugin) {
+        var category = block_plugin.get('category');
+        if (!categories_count[category]) {
+          categories_count[category] = 0;
+        }
+        ++categories_count[category];
+      });
+
+      // Render existing blocks as a unique category.
+      var existing_count = 0;
+      Drupal.panels_ipe.app.get('layout').get('regionCollection').each(function (region) {
+        region.get('blockCollection').each(function (block) {
+          ++existing_count;
+        });
+      });
+      this.$('.ipe-block-categories').append(this.template_category({
+        name: 'On Screen',
+        count: existing_count,
+        active: this.activeCategory === 'On Screen'
+      }));
+
+      // Render each category.
+      for (var i in categories_count) {
+        if (categories_count.hasOwnProperty(i)) {
+          this.$('.ipe-block-categories').append(this.template_category({
+            name: i,
+            count: categories_count[i],
+            active: this.activeCategory === i
+          }));
+        }
+      }
+
+      // Check if a category is selected. If so, render the top-tray.
+      if (this.activeCategory) {
+        var $top = this.$('.ipe-block-picker-top');
+        $top.addClass('active');
+        // The "On Screen" category is special, and requires special rendering.
+        if (this.activeCategory === 'On Screen') {
+          Drupal.panels_ipe.app.get('layout').get('regionCollection').each(function (region) {
+            region.get('blockCollection').each(function (block) {
+              $top.append(this.template_existing({
+                block: block.toJSON(),
+                region: region.toJSON()
+              }));
+            }, this);
+          }, this);
+        }
+        else {
+          this.collection.each(function (block_plugin) {
+            if (block_plugin.get('category') === this.activeCategory) {
+              $top.append(this.template_plugin(block_plugin.toJSON()));
+            }
+          }, this);
+        }
+
+        // Check if we need to automatically select one Block Plugin.
+        if (this.autoClick) {
+          this.$(this.autoClick).click();
+          this.autoClick = null;
+        }
+      }
+    },
+
+    /**
+     * Reacts to a category being clicked.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    toggleCategory: function (e) {
+      var category = $(e.currentTarget).data('block-category');
+
+      var animation = false;
+
+      // No category is open.
+      if (!this.activeCategory) {
+        this.activeCategory = category;
+        animation = 'slideDown';
+      }
+      // The same category is clicked twice.
+      else if (this.activeCategory === category) {
+        this.activeCategory = null;
+        animation = 'slideUp';
+      }
+      // Another category is already open.
+      else if (this.activeCategory) {
+        this.activeCategory = category;
+      }
+
+      // Trigger a re-render, with animation if needed.
+      if (animation === 'slideUp') {
+        // Close the tab, then re-render.
+        var self = this;
+        this.$('.ipe-block-picker-top')[animation]('fast', function () { self.render(); });
+      }
+      else if (animation === 'slideDown') {
+        // We need to render first as hypothetically nothing is open.
+        this.render();
+        this.$('.ipe-block-picker-top').hide();
+        this.$('.ipe-block-picker-top')[animation]('fast');
+      }
+      else {
+        this.render();
+      }
+    },
+
+    /**
+     * Displays a Block Configuration form when adding a Block Plugin.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    displayBlockPluginForm: function (e) {
+      var self = this;
+      var ajax_data = {
+        js: true
+      };
+
+      // Get the current plugin_id.
+      var plugin_id = $(e.currentTarget).data('plugin-id');
+
+      // Generate a base URL for the form.
+      var layout_id = Drupal.panels_ipe.app.get('layout').get('id');
+      var url = Drupal.panels_ipe.urlRoot(drupalSettings) + '/layout/' + layout_id + '/block_plugins/';
+
+      var plugin;
+
+      // This is a new block.
+      if (plugin_id) {
+        plugin = this.collection.get(plugin_id);
+        url += plugin_id + '/form';
+      }
+      // This is an existing block.
+      else {
+        // Get the Block UUID and Region Name
+        var block_id = $(e.currentTarget).data('existing-block-id');
+        var region_name = $(e.currentTarget).data('existing-region-name');
+
+        // Get the Block plugin
+        plugin = Drupal.panels_ipe.app.get('layout').get('regionCollection')
+          .get(region_name).get('blockCollection').get(block_id);
+        plugin_id = plugin.get('id');
+
+        // Send the BlockModel to the server when requesting the form.
+        ajax_data.block = plugin.toJSON();
+
+        url += plugin_id + '/block/' + block_id + '/form';
+      }
+
+      // Indicate an AJAX request.
+      this.$('.ipe-block-picker-top').fadeOut('fast', function () {
+        self.$('.ipe-block-picker-top').html(self.template_plugin_form(plugin.toJSON()));
+        self.$('.ipe-block-picker-top').fadeIn('fast');
+      });
+
+      // Setup the Drupal.Ajax instance.
+      var ajax = Drupal.ajax({
+        url: url,
+        submit: ajax_data
+      });
+
+      // Remove our throbber on load.
+      ajax.options.complete = function () {
+        self.$('.ipe-block-picker-top .ipe-icon-loading').remove();
+        self.$('#panels-ipe-block-plugin-form-wrapper').hide().fadeIn();
+      };
+
+      // Make the Drupal AJAX request.
+      ajax.execute();
+    }
+
+  });
+
+}(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..85e2079
--- /dev/null
+++ b/panels_ipe/js/views/BlockView.js
@@ -0,0 +1,111 @@
+/**
+ * @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_actions: _.template(
+      '<div class="ipe-actions-block ipe-actions" data-block-action-id="<%= uuid %>">' +
+      '  <h5>Block: <%= label %></h5>' +
+      '  <ul class="ipe-action-list">' +
+      '    <li data-action-id="remove">' +
+      '      <a><span class="ipe-icon ipe-icon-remove"></span></a>' +
+      '    </li>' +
+      '    <li data-action-id="up">' +
+      '      <a><span class="ipe-icon ipe-icon-up"></span></a>' +
+      '    </li>' +
+      '    <li data-action-id="down">' +
+      '      <a><span class="ipe-icon ipe-icon-down"></span></a>' +
+      '    </li>' +
+      '    <li data-action-id="move">' +
+      '      <select><option>Move</option></select>' +
+      '    </li>' +
+      '    <li data-action-id="configure">' +
+      '      <a><span class="ipe-icon ipe-icon-configure"></span></a>' +
+      '    </li>' +
+      '  </ul>' +
+      '</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;
+      // An element already exists and our HTML properly isn't set.
+      // This only occurs on initial page load for performance reasons.
+      if (options.el && !this.model.get('html')) {
+        this.model.set({html: this.$el.prop('outerHTML')});
+      }
+      this.listenTo(this.model, 'reset', this.render);
+      this.listenTo(this.model, 'change:active', this.render);
+    },
+
+    /**
+     * Renders the wrapping elements and refreshes a block model.
+     *
+     * @return {Drupal.panels_ipe.BlockView}
+     *   Return this, for chaining.
+     */
+    render: function () {
+      // Replace our current HTML.
+      this.$el.replaceWith(this.model.get('html'));
+      this.setElement("[data-block-id='" + this.model.get('uuid') + "']");
+
+      // We modify our content if the IPE is active.
+      if (this.model.get('active')) {
+        // Prepend the ipe-actions header.
+        this.$el.prepend(this.template_actions(this.model.toJSON()));
+
+        // Make ourselves draggable.
+        this.$el.draggable({
+          handle: '.ipe-actions',
+          scroll: true,
+          scrollSpeed: 20,
+          // Maintain our original width when dragging.
+          helper: function (e) {
+            var original = $(e.target).hasClass('ui-draggable') ? $(e.target) : $(e.target).closest('.ui-draggable');
+            return original.clone().css({
+              width: original.width()
+            });
+          },
+          start: function (e, ui) {
+            $('.ipe-droppable').addClass('active');
+          },
+          stop: function (e, ui) {
+            $('.ipe-droppable').removeClass('active');
+          },
+          opacity: .5
+        });
+      }
+
+      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..4689180
--- /dev/null
+++ b/panels_ipe/js/views/LayoutPicker.js
@@ -0,0 +1,115 @@
+/**
+ * @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_loading: _.template(
+      '<span class="ipe-icon ipe-icon-loading"></span>'
+    ),
+
+    /**
+     * @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) {
+        // Indicate an AJAX request.
+        this.$el.html(this.template_loading());
+
+        // Fetch a list of layouts from the server.
+        this.collection = new Drupal.panels_ipe.LayoutCollection();
+        var self = this;
+        this.collection.fetch().done(function () {
+          // We have a collection now, re-render ourselves.
+          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.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    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);
+          // Indicate an AJAX request.
+          this.$el.html(this.template_loading());
+
+          // Only the AppView is aware of the rendered Layout.
+          // @todo Investigate using non-global events.
+          Drupal.panels_ipe.app.trigger('changeLayout', [layout]);
+        }
+        else {
+          layout.set('current', false);
+        }
+      }, this);
+    }
+
+  });
+
+}(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..aac5f86
--- /dev/null
+++ b/panels_ipe/js/views/LayoutView.js
@@ -0,0 +1,446 @@
+/**
+ * @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 {function}
+     */
+    template_region_actions: _.template(
+      '<div class="ipe-actions" data-region-action-id="<%= name %>">' +
+      '  <h5>Region: <%= name %></h5>' +
+      '  <ul class="ipe-action-list"></ul>' +
+      '</div>'
+    ),
+
+    /**
+     * @type {function}
+     */
+    template_region_option: _.template(
+      '<option data-region-option-name="<%= name %>"><%= name %></option>'
+    ),
+
+    /**
+     * @type {function}
+     */
+    template_region_droppable: _.template(
+      '<div class="ipe-droppable" data-droppable-region-name="<%= region %>" data-droppable-index="<%= index %>"></div>'
+    ),
+
+    /**
+     * @type {Drupal.panels_ipe.LayoutModel}
+     */
+    model: null,
+
+    /**
+     * @type {Array}
+     *   An array of child Drupal.panels_ipe.BlockView objects.
+     */
+    blockViews: [],
+
+    /**
+     * @type {object}
+     */
+    events: {
+      'mousedown [data-action-id="move"] > select': 'showBlockRegionList',
+      'blur [data-action-id="move"] > select': 'hideBlockRegionList',
+      'change [data-action-id="move"] > select': 'selectBlockRegionList',
+      'click [data-action-id="up"]': 'moveBlock',
+      'click [data-action-id="down"]': 'moveBlock',
+      'click [data-action-id="remove"]': 'removeBlock',
+      'click [data-action-id="configure"]': 'configureBlock',
+      'drop .ipe-droppable': 'dropBlock'
+    },
+
+    /**
+     * @type {object}
+     */
+    droppable_settings: {
+      tolerance: 'pointer',
+      hoverClass: 'hover',
+      accept: '[data-block-id]'
+    },
+
+    /**
+     * @constructs
+     *
+     * @augments Backbone.View
+     *
+     * @param {object} options
+     *   An object with the following keys:
+     * @param {Drupal.panels_ipe.LayoutModel} options.model
+     *   The layout state model.
+     */
+    initialize: function (options) {
+      this.model = options.model;
+      // Initialize our html, this never changes.
+      if (this.model.get('html')) {
+        this.$el.html(this.model.get('html'));
+      }
+      this.listenTo(this.model, 'change:active', this.changeState);
+      this.listenTo(this.model, 'sync', this.modelSync);
+    },
+
+    /**
+     * Re-renders our blocks, we have no HTML to be re-rendered.
+     *
+     * @return {Drupal.panels_ipe.LayoutView}
+     *   Returns this, for chaining.
+     */
+    render: function () {
+      // Remove all existing BlockViews.
+      for (var i in this.blockViews) {
+        if (this.blockViews.hasOwnProperty(i)) {
+          this.blockViews[i].remove();
+        }
+      }
+      this.blockViews = [];
+
+      // Remove any active-state items that may remain rendered.
+      this.$('.ipe-actions').remove();
+      this.$('.ipe-droppable').remove();
+
+      // Re-attach all BlockViews to appropriate regions.
+      this.model.get('regionCollection').each(function (region) {
+        var region_selector = '[data-region-name="' + region.get('name') + '"]';
+
+        // Add an initial droppable area to our region if this is the first render.
+        if (this.model.get('active')) {
+          this.$(region_selector).prepend($(this.template_region_droppable({
+            region: region.get('name'),
+            index: 0
+          })).droppable(this.droppable_settings));
+
+          // Prepend the action header for this region.
+          this.$(region_selector).prepend(this.template_region_actions(region.toJSON()));
+        }
+
+        var i = 1;
+        region.get('blockCollection').each(function (block) {
+          var block_selector = '[data-block-id="' + block.get('uuid') + '"]';
+
+          // Attach an empty element for our View to attach itself to.
+          if (this.$(block_selector).length === 0) {
+            var empty_elem = $('<div data-block-id="' + block.get('uuid') + '">');
+            this.$(region_selector).append(empty_elem);
+          }
+
+          // Attach a View to this empty element.
+          var block_view = new Drupal.panels_ipe.BlockView({
+            model: block,
+            el: block_selector
+          });
+          this.blockViews.push(block_view);
+
+          // Render the new BlockView.
+          block_view.render();
+
+          // Prepend/append droppable regions if the Block is active.
+          if (this.model.get('active')) {
+            block_view.$el.after($(this.template_region_droppable({
+              region: region.get('name'),
+              index: i
+            })).droppable(this.droppable_settings));
+          }
+
+          ++i;
+        }, this);
+      }, this);
+
+      return this;
+    },
+
+    /**
+     * Prepends Regions and Blocks with action items.
+     *
+     * @param {Drupal.panels_ipe.LayoutModel} model
+     *   The target LayoutModel.
+     * @param {bool} value
+     *   The desired active state.
+     * @param {Object} options
+     *   Unused options.
+     */
+    changeState: function (model, value, options) {
+      // Sets the active state of child blocks when our state changes.
+      this.model.get('regionCollection').each(function (region) {
+        // BlockViews handle their own rendering, so just set the active value here.
+        region.get('blockCollection').each(function (block) {
+          block.set({active: value});
+        }, this);
+      }, this);
+
+      // Re-render ourselves.
+      this.render();
+    },
+
+    /**
+     * Replaces the "Move" button with a select list of regions.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    showBlockRegionList: function (e) {
+      // Get the BlockModel id (uuid).
+      var id = $(e.currentTarget).closest('[data-block-action-id]').data('block-action-id');
+
+      $(e.currentTarget).empty();
+
+      // Add other regions to select list.
+      this.model.get('regionCollection').each(function (region) {
+        var option = $(this.template_region_option(region.toJSON()));
+        // If this is the current region, place it first in the list.
+        if (region.get('blockCollection').get(id)) {
+          option.attr('selected', 'selected');
+          $(e.currentTarget).prepend(option);
+        }
+        else {
+          $(e.currentTarget).append(option);
+        }
+      }, this);
+    },
+
+    /**
+     * Hides the region selector.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    hideBlockRegionList: function (e) {
+      $(e.currentTarget).html('<option>Move</option>');
+    },
+
+    /**
+     * React to a new region being selected.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    selectBlockRegionList: function (e) {
+      // Get the BlockModel id (uuid).
+      var id = $(e.currentTarget).closest('[data-block-action-id]').data('block-action-id');
+
+      // Grab the value of this region.
+      var region_name = $(e.currentTarget).children(':selected').data('region-option-name');
+
+      // First, remove the Block from the current region.
+      var block;
+      var region_collection = this.model.get('regionCollection');
+      region_collection.each(function (region) {
+        var block_collection = region.get('blockCollection');
+        if (block_collection.get(id)) {
+          block = block_collection.get(id);
+          block_collection.remove(block);
+        }
+      });
+
+      // Next, add the Block to the new region.
+      if (block) {
+        var region = this.model.get('regionCollection').get(region_name);
+        region.get('blockCollection').add(block);
+      }
+
+      // Hide the select list.
+      this.hideBlockRegionList(e);
+
+      // Re-render.
+      this.render();
+
+      // Highlight the block.
+      this.$('[data-block-id="' + id + '"]').addClass('ipe-highlight');
+    },
+
+    /**
+     * Changes the LayoutModel for this view.
+     *
+     * @param {Drupal.panels_ipe.LayoutModel} layout
+     *   The new LayoutModel.
+     */
+    changeLayout: function (layout) {
+      // Stop listening to the current model.
+      this.stopListening(this.model);
+      // Initialize with the new model.
+      this.initialize({model: layout});
+    },
+
+    /**
+     * Moves a block up or down in its RegionModel's BlockCollection.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    moveBlock: function (e) {
+      // Get the BlockModel id (uuid).
+      var id = $(e.currentTarget).closest('[data-block-action-id]').data('block-action-id');
+
+      // Get the direction the block is moving.
+      var dir = $(e.currentTarget).data('action-id');
+
+      // Grab the model for this region.
+      var region_name = $(e.currentTarget).closest('[data-region-name]').data('region-name');
+      var region = this.model.get('regionCollection').get(region_name);
+      var block = region.get('blockCollection').get(id);
+
+      // Shift the Block.
+      region.get('blockCollection').shift(block, dir);
+
+      // Re-render ourselves.
+      this.render();
+
+      // Highlight the block.
+      this.$('[data-block-id="' + id + '"]').addClass('ipe-highlight');
+    },
+
+    /**
+     * Removes a Block from its region.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    removeBlock: function (e) {
+      // Get the BlockModel id (uuid).
+      var id = $(e.currentTarget).closest('[data-block-action-id]').data('block-action-id');
+
+      // Grab the model for this region.
+      var region_name = $(e.currentTarget).closest('[data-region-name]').data('region-name');
+      var region = this.model.get('regionCollection').get(region_name);
+
+      // Add the block to a collection of blocks to remove, if it isn't new.
+      if (!region.get('blockCollection').get(id).get('new')) {
+        this.model.get('deletedBlocks').push(id);
+      }
+
+      // Remove the block.
+      region.get('blockCollection').remove(id);
+
+      // Re-render ourselves.
+      this.render();
+    },
+
+    /**
+     * Configures an existing (on screen) Block.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    configureBlock: function (e) {
+      // Get the BlockModel id (uuid).
+      var id = $(e.currentTarget).closest('[data-block-action-id]').data('block-action-id');
+
+      // Grab the model for this region.
+      var region_name = $(e.currentTarget).closest('[data-region-name]').data('region-name');
+      var region = this.model.get('regionCollection').get(region_name);
+
+      // Send a App-level event so our BlockPicker View can respond and display a Form.
+      Drupal.panels_ipe.app.trigger('configureBlock', region.get('blockCollection').get(id));
+    },
+
+    /**
+     * Reacts to a block being dropped on a droppable region.
+     *
+     * @param {Object} e
+     *   The event object.
+     * @param {Object} ui
+     *   The jQuery UI object.
+     */
+    dropBlock: function (e, ui) {
+      // Get the BlockModel id (uuid) and old region name.
+      var id = ui.draggable.data('block-id');
+      var old_region_name = ui.draggable.closest('[data-region-name]').data('region-name');
+
+      // Get the BlockModel and remove it from its last position.
+      var old_region = this.model.get('regionCollection').get(old_region_name);
+      var block = old_region.get('blockCollection').get(id);
+      old_region.get('blockCollection').remove(block, {silent: true});
+
+      // Get the new region name and index from the droppable.
+      var new_region_name = $(e.currentTarget).data('droppable-region-name');
+      var index = $(e.currentTarget).data('droppable-index');
+
+      // Add the BlockModel to its new region/index.
+      var new_region = this.model.get('regionCollection').get(new_region_name);
+      new_region.get('blockCollection').add(block, {at: index, silent: true});
+
+      // Re-render ourselves.
+      // We do this twice as jQuery UI mucks with the DOM as it lets go of a
+      // cloned element. Typically we would only ever need to re-render once.
+      this.render().render();
+
+      // Highlight the block.
+      this.$('[data-block-id="' + id + '"]').addClass('ipe-highlight');
+    },
+
+    /**
+     * Adds a new BlockModel to the layout, or updates an existing Block model.
+     *
+     * @param {Drupal.panels_ipe.BlockModel} block
+     *   The new BlockModel
+     * @param {string} region_name
+     *   The region name that the block should be placed in.
+     */
+    addBlock: function (block, region_name) {
+      // First, check if the Block already exists and remove it if so.
+      var index = null;
+      this.model.get('regionCollection').each(function (region) {
+        if (region.get('blockCollection').get(block.get('uuid'))) {
+          index = region.get('blockCollection').indexOf(block.get('uuid'));
+          region.get('blockCollection').remove(block.get('uuid'));
+        }
+      });
+
+      // Get the target region.
+      var region = this.model.get('regionCollection').get(region_name);
+      if (region) {
+        // Add the block, at its previous index if necessary.
+        var options = {};
+        if (index) {
+          options.at = index;
+        }
+        region.get('blockCollection').add(block, options);
+
+        // Re-render ourselves.
+        this.render();
+
+        // Highlight the block.
+        this.$('[data-block-id="' + block.get('uuid') + '"]').addClass('ipe-highlight');
+      }
+    },
+
+    /**
+     * React to our LayoutModel being saved to the server.
+     */
+    modelSync: function () {
+      var new_blocks = this.model.get('newBlocks');
+
+      // Make sure our new BlockModels are no longer "new".
+      this.model.get('regionCollection').each(function (region) {
+        region.get('blockCollection').each(function (block) {
+          block.set({new: false});
+          // Check if this is a new block, in which case our placeholder UUID is replaced with a real one.
+          var old_uuid = block.get('uuid');
+          if (old_uuid in new_blocks) {
+            block.set({uuid: new_blocks[old_uuid]});
+            block.set({html: block.get('html').replace(old_uuid, new_blocks[old_uuid])});
+          }
+        }, this);
+      }, this);
+
+      // Reset special attributes we use to communicate with the backend.
+      this.model.set({
+        deletedBlocks: [],
+        newBlocks: {}
+      });
+
+      this.render();
+    }
+
+  });
+
+}(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..45edba9
--- /dev/null
+++ b/panels_ipe/js/views/TabsView.js
@@ -0,0 +1,178 @@
+/**
+ * @file
+ * The primary Backbone view for a tab collection.
+ *
+ * see Drupal.panels_ipe.TabCollection
+ */
+
+(function ($, _, Backbone, Drupal, drupalSettings) {
+
+  'use strict';
+
+  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="<%= title %>"><span class="ipe-icon ipe-icon-<% if (loading) { %>loading<% } else { print(id) } %>"></span><%= 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.
+     *
+     * @return {Drupal.panels_ipe.TabsView}
+     *   Return this, for chaining.
+     */
+    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.
+     *
+     * @param {Object} e
+     *   The event object.
+     */
+    switchTab: function (e) {
+      var id;
+      if (typeof e === 'string') {
+        id = e;
+      }
+      else {
+        e.preventDefault();
+        id = $(e.currentTarget).parent().data('tab-id');
+      }
+
+      // Disable all existing tabs.
+      var animation = null;
+      var already_open = false;
+      this.collection.each(function (tab) {
+        // If the tab is loading, do nothing.
+        if (tab.get('loading')) {
+          return;
+        }
+
+        // Don't repeat comparisons, if possible.
+        var clicked = tab.get('id') === id;
+        var active = tab.get('active');
+
+        // If the user is clicking the same tab twice, close it.
+        if (clicked && active) {
+          tab.set('active', false);
+          animation = 'close';
+        }
+        // If this is the first click, open the tab.
+        else if (clicked) {
+          tab.set('active', true);
+          // Only animate the tab if there is an associate Backbone View.
+          if (this.tabViews[id]) {
+            animation = 'open';
+          }
+        }
+        // The tab wasn't clicked, make sure it's closed.
+        else {
+          // Mark that the View was already open.
+          if (active) {
+            already_open = true;
+          }
+          tab.set('active', false);
+        }
+      }, this);
+
+      // Trigger a re-render, with animation if needed.
+      if (animation === 'close') {
+        this.closeTabContent();
+      }
+      else if (animation === 'open' && !already_open) {
+        this.openTabContent();
+      }
+      else {
+        this.render();
+      }
+    },
+
+    /**
+     * Closes any currently open tab.
+     */
+    closeTabContent: function () {
+      // Close the tab, then re-render.
+      var self = this;
+      this.$('.ipe-tabs-content')['slideUp']('fast', function () { self.render(); });
+    },
+
+    /**
+     * Opens any currently closed tab.
+     */
+    openTabContent: function () {
+      // We need to render first as hypothetically nothing is open.
+      this.render();
+      this.$('.ipe-tabs-content').hide();
+      this.$('.ipe-tabs-content')['slideDown']('fast');
+    }
+
+  });
+
+}(jQuery, _, Backbone, Drupal, drupalSettings));
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..d062063
--- /dev/null
+++ b/panels_ipe/panels_ipe.libraries.yml
@@ -0,0 +1,33 @@
+panels_ipe:
+  version: VERSION
+  js:
+    # Core.
+    js/panels_ipe.js: {}
+    # Models.
+    js/models/AppModel.js: {}
+    js/models/BlockModel.js: {}
+    js/models/BlockPluginModel.js: {}
+    js/models/RegionModel.js: {}
+    js/models/TabModel.js: {}
+    js/models/LayoutModel.js: {}
+    # Views.
+    js/views/AppView.js: {}
+    js/views/BlockView.js: {}
+    js/views/BlockPicker.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.ui.droppable
+    - core/underscore
+    - core/backbone
+    - core/drupal
+    - core/drupal.form
+    - core/drupalSettings
diff --git a/panels_ipe/panels_ipe.module b/panels_ipe/panels_ipe.module
new file mode 100644
index 0000000..18d7d94
--- /dev/null
+++ b/panels_ipe/panels_ipe.module
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * @file
+ * Contains panels_ipe.module.
+ */
+
+use Drupal\Core\Routing\RouteMatchInterface;
+
+/**
+ * Implements hook_help().
+ */
+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;
+
+    default:
+  }
+}
+
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..61574d7
--- /dev/null
+++ b/panels_ipe/panels_ipe.routing.yml
@@ -0,0 +1,53 @@
+panels_ipe.block_plugins:
+  path: '/admin/panels_ipe/variant/{variant_id}/block_plugins'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::getBlockPlugins'
+  requirements:
+    _permission: 'access panels in-place editing'
+    _method: 'GET'
+
+panels_ipe.block_plugin.form:
+  path: '/admin/panels_ipe/variant/{variant_id}/layout/{layout_id}/block_plugins/{plugin_id}/form'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::getBlockPluginForm'
+  requirements:
+    _permission: 'access panels in-place editing'
+
+panels_ipe.block_plugin_existing.form:
+  path: '/admin/panels_ipe/variant/{variant_id}/layout/{layout_id}/block_plugins/{plugin_id}/block/{block_uuid}/form'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::getBlockPluginForm'
+  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'
+    _method: 'GET'
+
+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'
+    _method: 'GET'
+
+panels_ipe.layout.update:
+  path: '/admin/panels_ipe/variant/{variant_id}/layouts/{layout_id}'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::updateLayout'
+  requirements:
+    _permission: 'access panels in-place editing'
+    _method: 'PUT'
+
+panels_ipe.layout.save:
+  path: '/admin/panels_ipe/variant/{variant_id}/layouts/{layout_id}'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::createLayout'
+  requirements:
+    _permission: 'access panels in-place editing'
+    _method: 'POST'
\ No newline at end of file
diff --git a/panels_ipe/src/Controller/PanelsIPEPageController.php b/panels_ipe/src/Controller/PanelsIPEPageController.php
new file mode 100644
index 0000000..316f8a1
--- /dev/null
+++ b/panels_ipe/src/Controller/PanelsIPEPageController.php
@@ -0,0 +1,405 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\panels_ipe\Controller\PanelsIPEPageController.
+ */
+
+namespace Drupal\panels_ipe\Controller;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\AppendCommand;
+use Drupal\Core\Block\BlockManagerInterface;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Plugin\Context\ContextHandlerInterface;
+use Drupal\Core\Render\Element;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\layout_plugin\Plugin\Layout\LayoutPluginManagerInterface;
+use Drupal\page_manager\Entity\PageVariant;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+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\Plugin\Context\ContextHandlerInterface $contextHandler
+   */
+  protected $contextHandler;
+
+  /**
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * @var \Drupal\layout_plugin\Plugin\Layout\LayoutPluginManagerInterface
+   */
+  protected $layoutPluginManager;
+
+  /**
+   * Constructs a new PanelsIPEController.
+   *
+   * @param \Drupal\Core\Block\BlockManagerInterface $block_manager
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   * @param \Drupal\layout_plugin\Plugin\Layout\LayoutPluginManagerInterface $layout_plugin_manager
+   * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler
+   */
+  public function __construct(BlockManagerInterface $block_manager, RendererInterface $renderer, LayoutPluginManagerInterface $layout_plugin_manager, ContextHandlerInterface $context_handler) {
+    $this->blockManager = $block_manager;
+    $this->renderer = $renderer;
+    $this->layoutPluginManager = $layout_plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.block'),
+      $container->get('renderer'),
+      $container->get('plugin.manager.layout_plugin'),
+      $container->get('context.handler')
+    );
+  }
+
+  /**
+   * 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();
+    }
+
+    // Check variant access.
+    if (!$variant->access('read')) {
+      throw new AccessDeniedHttpException();
+    }
+
+    /** @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 = $this->layoutPluginManager->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();
+    }
+
+    // Check variant access.
+    if (!$variant->access('read')) {
+      throw new AccessDeniedHttpException();
+    }
+
+    /** @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);
+
+    // Inherit our PageVariant's contexts before rendering.
+    $variant_plugin->setContexts($variant->getContexts());
+
+    $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 = $this->layoutPluginManager->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);
+  }
+
+  /**
+   * Updates the current PageVariant based on the changes done in our app.
+   *
+   * @param \Drupal\page_manager\PageVariantInterface $variant
+   *   The current variant.
+   * @param array $layout
+   *   The decoded LayoutModel from our App.
+   *
+   * @return JsonResponse
+   */
+  protected function updateVariant($variant, $layout) {
+    // Load the current variant plugin.
+    /** @var \Drupal\panels\Plugin\DisplayVariant\PanelsDisplayVariant $variant_plugin */
+    $variant_plugin = $variant->getVariantPlugin();
+
+    // Change the layout.
+    $configuration = $variant_plugin->getConfiguration();
+    $configuration['layout'] = $layout['id'];
+    $variant_plugin->setConfiguration($configuration);
+    $variant_plugin->getLayout();
+
+    // Edit our blocks.
+    $return_data = ['newBlocks' => []];
+    foreach ($layout['regionCollection'] as $region) {
+      $weight = 0;
+      foreach ($region['blockCollection'] as $block) {
+        // Our Backbone app models Blocks to abstract their region.
+        $block['region'] = $region['name'];
+
+        // Weight is based by order in the collection.
+        $block['weight'] = ++$weight;
+
+        // @todo This should be removed in Backbone.
+        unset($block['html']);
+        unset($block['active']);
+        unset($block['new']);
+
+        // If the block already exists, update it. Otherwise add it.
+        if (isset($configuration['blocks'][$block['uuid']])) {
+          $variant_plugin->updateBlock($block['uuid'], NestedArray::mergeDeep($configuration['blocks'][$block['uuid']], $block));
+        }
+        else {
+          $new_uuid = $variant_plugin->addBlock($block);
+          $return_data['newBlocks'][$block['uuid']] = $new_uuid;
+        }
+      }
+    }
+
+    // Remove blocks if needed.
+    foreach ($layout['deletedBlocks'] as $uuid) {
+      $variant_plugin->removeBlock($uuid);
+    }
+
+    // Save the plugin.
+    $variant->save();
+
+    return new JsonResponse($return_data);
+  }
+
+  /**
+   * Updates (PUT) an existing Layout in this Variant.
+   *
+   * @param string $variant_id
+   *   The machine name of the current display variant.
+   * @param string $layout_id
+   *   The machine name of the requested layout.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   *
+   * @return JsonResponse
+   *
+   * @throws AccessDeniedHttpException|NotFoundHttpException
+   */
+  public function updateLayout($variant_id, $layout_id, Request $request) {
+    // Check if the variant exists.
+    /** @var \Drupal\page_manager\PageVariantInterface $variant */
+    if (!$variant = PageVariant::load($variant_id)) {
+      throw new NotFoundHttpException();
+    }
+
+    // Check variant access.
+    if (!$variant->access('update')) {
+      throw new AccessDeniedHttpException();
+    }
+
+    // Decode the request.
+    $content = $request->getContent();
+    if (!empty($content) && $layout = Json::decode($content)) {
+      return $this->updateVariant($variant, $layout);
+    }
+    else {
+      return new JsonResponse(['success' => false], 400);
+    }
+  }
+
+  /**
+   * Creates (POST) a new Layout for this Variant.
+   *
+   * @param string $variant_id
+   *   The machine name of the current display variant.
+   * @param string $layout_id
+   *   The machine name of the new layout.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   *
+   * @return JsonResponse
+   *
+   * @throws AccessDeniedHttpException|NotFoundHttpException
+   */
+  public function createLayout($variant_id, $layout_id, Request $request) {
+    // For now, creating and updating a layout is the same thing.
+    return $this->updateLayout($variant_id, $layout_id, $request);
+  }
+
+  /**
+   * Gets a list of Block Plugins from the server.
+   *
+   * @param string $variant_id
+   *   The PageVariant ID.
+   *
+   * @return JsonResponse
+   */
+  public function getBlockPlugins($variant_id) {
+    // Check if the variant exists.
+    /** @var \Drupal\page_manager\PageVariantInterface $variant */
+    if (!$variant = PageVariant::load($variant_id)) {
+      throw new NotFoundHttpException();
+    }
+
+    // Get block plugin definitions from the server.
+    $definitions = $this->blockManager->getDefinitionsForContexts($variant->getContexts());
+
+    // Assemble our relevant data.
+    $data = [];
+    foreach ($definitions as $plugin_id => $definition) {
+      // Don't add broken Blocks.
+      if ($plugin_id == 'broken') {
+        continue;
+      }
+      $data[] = [
+        'plugin_id' => $plugin_id,
+        'label' => $definition['admin_label'],
+        'category' => $definition['category'],
+        'id' => $definition['id'],
+        'provider' => $definition['provider']
+      ];
+    }
+
+    // Return a structured JSON response for our Backbone App.
+    return new JsonResponse($data);
+  }
+
+  /**
+   * Drupal AJAX compatible route for rendering a given Block Plugin's form.
+   *
+   * @param string $variant_id
+   *   The PageVariant ID.
+   * @param string $layout_id
+   *   The requested Layout ID.
+   * @param string $plugin_id
+   *   The requested Block Plugin ID.
+   * @param string $block_uuid
+   *   The Block UUID, if this is an existing Block.
+   *
+   * @return Response
+   *
+   * @throws NotFoundHttpException
+   */
+  public function getBlockPluginForm($variant_id, $layout_id, $plugin_id, $block_uuid = NULL) {
+    // Check if the variant exists.
+    /** @var \Drupal\page_manager\PageVariantInterface $variant */
+    if (!$variant = PageVariant::load($variant_id)) {
+      throw new NotFoundHttpException();
+    }
+
+    // Get the configuration in the block plugin definition.
+    $definitions = $this->blockManager->getDefinitionsForContexts($variant->getContexts());
+
+    // Check if the block plugin is defined.
+    if (!isset($definitions[$plugin_id])) {
+      throw new NotFoundHttpException();
+    }
+
+    // If $block_uuid is passed, check if it already exists in the Variant Plugin.
+    $new = TRUE;
+    if ($block_uuid) {
+      /** @var \Drupal\panels\Plugin\DisplayVariant\PanelsDisplayVariant $variant_plugin */
+      $plugin = $variant->getVariantPlugin();
+      $new = isset($plugin->getConfiguration()['blocks'][$block_uuid]);
+    }
+
+    // Grab the current layout's regions.
+    /** @var \Drupal\layout_plugin\Plugin\Layout\LayoutBase $layout */
+    $layout = $this->layoutPluginManager->createInstance($layout_id, []);
+    $regions = $layout->getRegionNames();
+
+    // Build a Block Plugin configuration form.
+    $form = \Drupal::formBuilder()->getForm('Drupal\panels_ipe\Form\PanelsIPEBlockPluginForm', $plugin_id, $variant_id, $regions, $block_uuid, $new);
+
+    // Return the rendered form as a proper Drupal AJAX response.
+    // This is needed as forms often have custom JS and CSS that need added,
+    // and it isn't worth replicating things that work in Drupal with Backbone.
+    $response = new AjaxResponse();
+    $command = new AppendCommand('.ipe-block-plugin-form', $form);
+    $response->addCommand($command);
+    return $response;
+  }
+
+}
diff --git a/panels_ipe/src/Form/PanelsIPEBlockPluginForm.php b/panels_ipe/src/Form/PanelsIPEBlockPluginForm.php
new file mode 100644
index 0000000..549bc84
--- /dev/null
+++ b/panels_ipe/src/Form/PanelsIPEBlockPluginForm.php
@@ -0,0 +1,243 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\panels_ipe\Form\PanelsIPEBlockPluginForm.
+ */
+
+namespace Drupal\panels_ipe\Form;
+
+use Drupal\Component\Plugin\PluginManagerInterface;
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\Context\ContextHandlerInterface;
+use Drupal\Component\Plugin\ContextAwarePluginInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\page_manager\Entity\PageVariant;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form for adding a block plugin temporarily using AJAX.
+ *
+ * Unlike most forms, this never saves a block plugin instance or persists it
+ * from state to state. This is only for the initial addition to the Layout.
+ */
+class PanelsIPEBlockPluginForm extends FormBase {
+
+  /**
+   * @var \Drupal\Component\Plugin\PluginManagerInterface $blockManager
+   */
+  protected $blockManager;
+
+  /**
+   * @var \Drupal\Core\Plugin\Context\ContextHandlerInterface $contextHandler
+   */
+  protected $contextHandler;
+
+  /**
+   * @var \Drupal\Core\Render\RendererInterface $renderer
+   */
+  protected $renderer;
+
+  /**
+   * Constructs a new PanelsIPEBlockPluginForm.
+   *
+   * @param \Drupal\Component\Plugin\PluginManagerInterface $block_manager
+   * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   */
+  public function __construct(PluginManagerInterface $block_manager, ContextHandlerInterface $context_handler, RendererInterface $renderer) {
+    $this->blockManager = $block_manager;
+    $this->contextHandler = $context_handler;
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.block'),
+      $container->get('context.handler'),
+      $container->get('renderer')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'panels_ipe_block_plugin_form';
+  }
+
+  /**
+   * Builds a form that constructs a unsaved instance of a Block for the IPE.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   * @param string $plugin_id
+   *   The requested Block Plugin ID.
+   * @param string $variant_id
+   *   The current PageVariant ID.
+   * @param array $regions
+   *   An array of region definitions.
+   * @param string $uuid
+   *   An optional Block UUID, if this is an existing Block.
+   * @param bool $new
+   *   Whether or not the Block is new (to the Variant Plugin).
+   *
+   * @return array
+   *   The form structure.
+   *
+   */
+  public function buildForm(array $form, FormStateInterface $form_state, $plugin_id = NULL, $variant_id = NULL, $regions = NULL, $uuid = NULL, $new = TRUE) {
+    // We require these defaults arguments.
+    if (!$plugin_id || !$variant_id || !$regions) {
+      return FALSE;
+    }
+
+    // Create an instance of this Block plugin.
+    /** @var \Drupal\Core\Block\BlockBase $block_instance */
+    $block_instance = $this->blockManager->createInstance($plugin_id);
+
+    // Check to see if an existing block is present.
+    $input = $form_state->getUserInput();
+    if (isset($input['block'])) {
+      // @todo This should be removed in Backbone.
+      unset($input['block']['html']);
+      unset($input['block']['active']);
+      unset($input['block']['new']);
+      $block_instance->setConfiguration($input['block']);
+    }
+
+    // Wrap the form so that our AJAX submit can replace its contents.
+    $form['#prefix'] = '<div id="panels-ipe-block-plugin-form-wrapper">';
+    $form['#suffix'] = '</div>';
+
+    // Get the base configuration form for this block.
+    $form['settings'] = $block_instance->buildConfigurationForm([], $form_state);
+
+    // Add the block and variant ID to the form as a temporary value.
+    $form_state->setTemporaryValue('plugin_id', $plugin_id);
+    $form_state->setTemporaryValue('variant_id', $variant_id);
+
+    // Add our current UUID and new status to the form to mark this block as existing.
+    $form_state->setTemporaryValue('uuid', $uuid);
+    $form_state->setTemporaryValue('new', $new);
+
+    // Add a select list for region assignment.
+    $form['region'] = [
+      '#title' => $this->t('Region'),
+      '#type' => 'select',
+      '#options' => $regions,
+      '#required' => TRUE,
+      '#default_value' => reset($regions)
+    ];
+
+    // Add an add button, which is only used by our App.
+    $form['submit'] = [
+      '#type' => 'button',
+      '#value' => $this->t('Submit'),
+      '#ajax' => [
+        'callback' => '::submitForm',
+        'wrapper' => 'panels-ipe-block-plugin-form-wrapper',
+        'method' => 'replace',
+        'progress' => [
+          'type' => 'throbber',
+          'message' => ''
+        ]
+      ]
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    $block_instance = $this->getBlockInstance($form_state->getTemporaryValue('variant_id'), $form_state->getTemporaryValue('plugin_id'));
+
+    $block_instance->validateConfigurationForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $block_instance = $this->getBlockInstance($form_state->getTemporaryValue('variant_id'), $form_state->getTemporaryValue('plugin_id'));
+
+    // Submit the configuration form.
+    $block_instance->submitConfigurationForm($form, $form_state);
+
+    // Get the new block configuration.
+    $configuration = $block_instance->getConfiguration();
+
+    // Add block settings to the form so that we can create the new BlockModel.
+    $uuid = $form_state->getTemporaryValue('uuid') ? $form_state->getTemporaryValue('uuid') : \Drupal::service('uuid')->generate();
+    $elements = [
+      '#theme' => 'block',
+      '#attributes' => [
+        'data-block-id' => $uuid
+      ],
+      '#configuration' => $configuration,
+      '#plugin_id' => $block_instance->getPluginId(),
+      '#base_plugin_id' => $block_instance->getBaseId(),
+      '#derivative_plugin_id' => $block_instance->getDerivativeId(),
+      'content' => $block_instance->build()
+    ];
+
+    $settings = [
+      'uuid' => $uuid,
+      'label' => empty($configuration['label']) ? $block_instance->label() : $configuration['label'],
+      'id' => $block_instance->getPluginId(),
+      'region' => $form_state->getValue('region'),
+      'html' => $this->renderer->render($elements),
+      'new' => $form_state->getTemporaryValue('new') ? $form_state->getTemporaryValue('new') : TRUE
+    ];
+
+    // Merge in the current configuration.
+    $settings = NestedArray::mergeDeep($configuration, $settings);
+
+    $form = [
+      '#type' => 'container',
+      '#attributes' => [
+        'id' => 'panels-ipe-block-plugin-form-json'
+      ],
+      ['#markup' => Json::encode($settings)]
+    ];
+
+    return $form;
+  }
+
+  /**
+   * Creates a Block Plugin instance suitable for rendering or testing.
+   *
+   * @param string $variant_id
+   *   The Variant ID.
+   * @param string $plugin_id
+   *   The Block Plugin ID.
+   *
+   * @return \Drupal\Core\Block\BlockBase
+   *   The Block Plugin instance.
+   */
+  protected function getBlockInstance($variant_id, $plugin_id) {
+    // Create an instance of this Block plugin.
+    /** @var \Drupal\Core\Block\BlockBase $block_instance */
+    $block_instance = $this->blockManager->createInstance($plugin_id);
+
+    // Add context to the block.
+    if ($block_instance instanceof ContextAwarePluginInterface) {
+      /** @var \Drupal\page_manager\PageVariantInterface $variant */
+      $variant = PageVariant::load($variant_id);
+      $this->contextHandler->applyContextMapping($block_instance, $variant->getContexts());
+    }
+
+    return $block_instance;
+  }
+
+}
diff --git a/panels_ipe/src/Plugin/DisplayBuilder/InPlaceEditorDisplayBuilder.php b/panels_ipe/src/Plugin/DisplayBuilder/InPlaceEditorDisplayBuilder.php
new file mode 100644
index 0000000..c75de60
--- /dev/null
+++ b/panels_ipe/src/Plugin/DisplayBuilder/InPlaceEditorDisplayBuilder.php
@@ -0,0 +1,124 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\panels_ipe\Plugin\DisplayBuilder\InPlaceEditorDisplayBuilder.
+ */
+
+namespace Drupal\panels_ipe\Plugin\DisplayBuilder;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\NestedArray;
+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.
+   * @param \Drupal\layout_plugin\Plugin\Layout\LayoutInterface $layout
+   *   The current layout.
+   *
+   * @return array
+   *   An associative array representing the contents of drupalSettings.
+   */
+  protected function getDrupalSettings(array $regions, LayoutInterface $layout) {
+    // Explicitly support Page Manger, as we need to have a reference for where
+    // to save the display.
+    /** @var \Drupal\page_manager\PageVariantInterface $variant */
+    $variant = \Drupal::request()->attributes->get('page_manager_page_variant');
+
+    $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) {
+        $configuration = $block->getConfiguration();
+        $setting = [
+          'uuid' => $block_uuid,
+          'label' => $block->label(),
+          'id' => $block->getPluginId()
+        ];
+        $settings['regions'][$region]['blocks'][$block_uuid] = NestedArray::mergeDeep($configuration, $setting);
+      }
+    }
+
+    // Add the layout information.
+    $layout_definition = $layout->getPluginDefinition();
+    $settings['layout'] = [
+      'id' => $layout->getPluginId(),
+      'label' => $layout_definition['label'],
+      'original' => true
+    ];
+
+    // Add the display variant's config.
+    if ($variant) {
+      $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, $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;
+  }
+
+}
