Index: resources/celerity/map.php
===================================================================
--- resources/celerity/map.php
+++ resources/celerity/map.php
@@ -7,7 +7,7 @@
 return array(
   'names' =>
   array(
-    'core.pkg.css' => '9567aaae',
+    'core.pkg.css' => '16ef3940',
     'core.pkg.js' => 'c907bd96',
     'darkconsole.pkg.js' => 'ca8671ce',
     'differential.pkg.css' => '827749c1',
@@ -137,7 +137,7 @@
     'rsrc/css/phui/phui-info-panel.css' => '27ea50a1',
     'rsrc/css/phui/phui-list.css' => '2edb76cf',
     'rsrc/css/phui/phui-object-box.css' => '4f916b80',
-    'rsrc/css/phui/phui-object-item-list-view.css' => 'd65c06e7',
+    'rsrc/css/phui/phui-object-item-list-view.css' => '00fdad60',
     'rsrc/css/phui/phui-pinboard-view.css' => '53c5fca0',
     'rsrc/css/phui/phui-property-list-view.css' => '354465ae',
     'rsrc/css/phui/phui-remarkup-preview.css' => '19ad512b',
@@ -392,6 +392,7 @@
     'rsrc/js/application/policy/behavior-policy-control.js' => 'c01153ea',
     'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '263aeb8c',
     'rsrc/js/application/ponder/behavior-votebox.js' => '327dbe61',
+    'rsrc/js/application/projects/behavior-project-boards.js' => 'd4cbe3d5',
     'rsrc/js/application/projects/behavior-project-create.js' => '065227cc',
     'rsrc/js/application/releeph/releeph-preview-branch.js' => '9eb2cedb',
     'rsrc/js/application/releeph/releeph-request-state-change.js' => 'fe7fc914',
@@ -416,7 +417,7 @@
     'rsrc/js/application/uiexample/notification-example.js' => 'c51a6616',
     'rsrc/js/core/Busy.js' => '6453c869',
     'rsrc/js/core/DragAndDropFileUpload.js' => 'ae6abfba',
-    'rsrc/js/core/DraggableList.js' => '6f5a879c',
+    'rsrc/js/core/DraggableList.js' => '880f3d2d',
     'rsrc/js/core/DropdownMenu.js' => '2f6f80f4',
     'rsrc/js/core/DropdownMenuItem.js' => '0f386ef4',
     'rsrc/js/core/FileUpload.js' => '96713558',
@@ -602,6 +603,7 @@
     'javelin-behavior-policy-control' => 'c01153ea',
     'javelin-behavior-policy-rule-editor' => '263aeb8c',
     'javelin-behavior-ponder-votebox' => '327dbe61',
+    'javelin-behavior-project-boards' => 'd4cbe3d5',
     'javelin-behavior-project-create' => '065227cc',
     'javelin-behavior-refresh-csrf' => 'c4b31646',
     'javelin-behavior-releeph-preview-branch' => '9eb2cedb',
@@ -673,7 +675,7 @@
     'phabricator-countdown-css' => '86b7b0a0',
     'phabricator-crumbs-view-css' => '2d9db584',
     'phabricator-drag-and-drop-file-upload' => 'ae6abfba',
-    'phabricator-draggable-list' => '6f5a879c',
+    'phabricator-draggable-list' => '880f3d2d',
     'phabricator-dropdown-menu' => '2f6f80f4',
     'phabricator-fatal-config-template-css' => '25d446d6',
     'phabricator-feed-css' => '4716c86f',
@@ -741,7 +743,7 @@
     'phui-info-panel-css' => '27ea50a1',
     'phui-list-view-css' => '2edb76cf',
     'phui-object-box-css' => '4f916b80',
-    'phui-object-item-list-view-css' => 'd65c06e7',
+    'phui-object-item-list-view-css' => '00fdad60',
     'phui-pinboard-view-css' => '53c5fca0',
     'phui-property-list-view-css' => '354465ae',
     'phui-remarkup-preview-css' => '19ad512b',
@@ -1192,15 +1194,6 @@
       1 => 'javelin-dom',
       2 => 'javelin-workflow',
     ),
-    '6f5a879c' =>
-    array(
-      0 => 'javelin-install',
-      1 => 'javelin-dom',
-      2 => 'javelin-stratcom',
-      3 => 'javelin-util',
-      4 => 'javelin-vector',
-      5 => 'javelin-magical-init',
-    ),
     '71755c79' =>
     array(
       0 => 'javelin-behavior',
@@ -1326,6 +1319,15 @@
       6 => 'javelin-history',
       7 => 'javelin-vector',
     ),
+    '880f3d2d' =>
+    array(
+      0 => 'javelin-install',
+      1 => 'javelin-dom',
+      2 => 'javelin-stratcom',
+      3 => 'javelin-util',
+      4 => 'javelin-vector',
+      5 => 'javelin-magical-init',
+    ),
     '8a3ed18b' =>
     array(
       0 => 'javelin-magical-init',
@@ -1711,6 +1713,13 @@
       1 => 'javelin-dom',
       2 => 'javelin-view',
     ),
+    'd4cbe3d5' =>
+    array(
+      0 => 'javelin-behavior',
+      1 => 'javelin-dom',
+      2 => 'javelin-util',
+      3 => 'phabricator-draggable-list',
+    ),
     'd6ca6b1c' =>
     array(
       0 => 'javelin-install',
Index: src/applications/project/controller/PhabricatorProjectBoardController.php
===================================================================
--- src/applications/project/controller/PhabricatorProjectBoardController.php
+++ src/applications/project/controller/PhabricatorProjectBoardController.php
@@ -61,9 +61,18 @@
       $task_map[$default_phid][] = $task->getPHID();
     }
 
+    $board_id = celerity_generate_unique_node_id();
+
     $board = id(new PHUIWorkboardView())
       ->setUser($viewer)
-      ->setFluidishLayout(true);
+      ->setFluidishLayout(true)
+      ->setID($board_id);
+
+    $this->initBehavior(
+      'project-boards',
+      array(
+        'boardID' => $board_id,
+      ));
 
     foreach ($columns as $column) {
       $panel = id(new PHUIWorkpanelView())
@@ -74,7 +83,8 @@
       $cards = id(new PHUIObjectItemListView())
         ->setUser($viewer)
         ->setCards(true)
-        ->setFlush(true);
+        ->setFlush(true)
+        ->addSigil('project-column');
       $task_phids = idx($task_map, $column->getPHID(), array());
       foreach (array_select_keys($tasks, $task_phids) as $task) {
         $cards->addItem($this->renderTaskCard($task));
@@ -148,6 +158,7 @@
       ->setHeader($task->getTitle())
       ->setGrippable($can_edit)
       ->setHref('/T'.$task->getID())
+      ->addSigil('project-card')
       ->addAction(
         id(new PHUIListItemView())
           ->setName(pht('Edit'))
Index: webroot/rsrc/css/phui/phui-object-item-list-view.css
===================================================================
--- webroot/rsrc/css/phui/phui-object-item-list-view.css
+++ webroot/rsrc/css/phui/phui-object-item-list-view.css
@@ -572,3 +572,7 @@
   padding-top: 0;
 }
 
+.drag-target-list {
+  /* TODO: This is a work in progress. */
+  background: red;
+}
Index: webroot/rsrc/js/application/projects/behavior-project-boards.js
===================================================================
--- /dev/null
+++ webroot/rsrc/js/application/projects/behavior-project-boards.js
@@ -0,0 +1,29 @@
+/**
+ * @provides javelin-behavior-project-boards
+ * @requires javelin-behavior
+ *           javelin-dom
+ *           javelin-util
+ *           phabricator-draggable-list
+ */
+
+JX.behavior('project-boards', function(config) {
+
+  function finditems(col) {
+    return JX.DOM.scry(col, 'li', 'project-card');
+  }
+
+  var lists = [];
+  var ii;
+  var cols = JX.DOM.scry(JX.$(config.boardID), 'ul', 'project-column');
+
+  for (ii = 0; ii < cols.length; ii++) {
+    var list = new JX.DraggableList('project-card', cols[ii])
+      .setFindItemsHandler(JX.bind(null, finditems, cols[ii]));
+    lists.push(list);
+  }
+
+  for (ii = 0; ii < lists.length; ii++) {
+    lists[ii].setGroup(lists);
+  }
+
+});
Index: webroot/rsrc/js/core/DraggableList.js
===================================================================
--- webroot/rsrc/js/core/DraggableList.js
+++ webroot/rsrc/js/core/DraggableList.js
@@ -14,6 +14,7 @@
   construct : function(sigil, root) {
     this._sigil = sigil;
     this._root = root || document.body;
+    this._group = [this];
 
     // NOTE: Javelin does not dispatch mousemove by default.
     JX.enableDispatch(document.body, 'mousemove');
@@ -46,6 +47,11 @@
     _dimensions : null,
     _ghostHandler : null,
     _ghostNode : null,
+    _group : null,
+
+    getRootNode : function() {
+      return this._root;
+    },
 
     setGhostHandler : function(handler) {
       this._ghostHandler = handler;
@@ -68,8 +74,41 @@
       return this;
     },
 
+    setGroup : function(lists) {
+      var result = [];
+      var need_self = true;
+      for (var ii = 0; ii < lists.length; ii++) {
+        if (lists[ii] == this) {
+          need_self = false;
+        }
+        result.push(lists[ii]);
+      }
+
+      if (need_self) {
+        result.push(this);
+      }
+
+      this._group = result;
+      return this;
+    },
+
+    _canDragX : function() {
+      return this._hasGroup();
+    },
+
+    _hasGroup : function() {
+      return (this._group.length > 1);
+    },
+
     _defaultGhostHandler : function(ghost, target) {
-      var parent = this._dragging.parentNode;
+      var parent;
+
+      if (!this._hasGroup()) {
+        parent = this._dragging.parentNode;
+      } else {
+        parent = this.getRootNode();
+      }
+
       if (target && target.nextSibling) {
         parent.insertBefore(ghost, target.nextSibling);
       } else if (!target && parent.firstChild) {
@@ -116,6 +155,24 @@
       this._origin = JX.$V(e);
       this._dimensions = JX.$V(this._dragging);
 
+      for (var ii = 0; ii < this._group.length; ii++) {
+        this._group[ii]._clearTarget();
+        this._group[ii]._generateTargets();
+      }
+
+      if (!this.invoke('didBeginDrag', this._dragging).getPrevented()) {
+        // Set the height of all the ghosts in the group. In the normal case,
+        // this just sets this list's ghost height.
+        for (var jj = 0; jj < this._group.length; jj++) {
+          var ghost = this._group[jj].getGhostNode();
+          ghost.style.height = JX.Vector.getDim(this._dragging).y + 'px';
+        }
+
+        JX.DOM.alterClass(this._dragging, 'drag-dragging', true);
+      }
+    },
+
+    _generateTargets : function() {
       var targets = [];
       var items = this.findItems();
       for (var ii = 0; ii < items.length; ii++) {
@@ -126,30 +183,73 @@
       }
       targets.sort(function(u, v) { return v.y - u.y; });
       this._targets = targets;
-      this._target = false;
 
-      if (!this.invoke('didBeginDrag', this._dragging).getPrevented()) {
-        var ghost = this.getGhostNode();
-        ghost.style.height = JX.Vector.getDim(this._dragging).y + 'px';
-        JX.DOM.alterClass(this._dragging, 'drag-dragging', true);
+      return this;
+    },
+
+    _getTargetList : function(p) {
+      var target_list;
+      if (this._hasGroup()) {
+        var group = this._group;
+        for (var ii = 0; ii < group.length; ii++) {
+          var root = group[ii].getRootNode();
+          var rp = JX.$V(root);
+          var rd = JX.Vector.getDim(root);
+
+          var is_target = false;
+          if (p.x >= rp.x && p.y >= rp.y) {
+            if (p.x <= (rp.x + rd.x) && p.y <= (rp.y + rd.y)) {
+              is_target = true;
+              target_list = group[ii];
+            }
+          }
+
+          JX.DOM.alterClass(root, 'drag-target-list', is_target);
+        }
+      } else {
+        target_list = this;
       }
+
+      return target_list;
     },
 
-    _onmove : function(e) {
-      if (!this._dragging) {
-        return;
+    _setTarget : function(cur_target) {
+      var ghost = this.getGhostNode();
+      var target = this._target;
+
+      if (cur_target !== target) {
+        this._clearTarget();
+        if (cur_target !== false) {
+          var ok = this.getGhostHandler()(ghost, cur_target);
+          // If the handler returns explicit `false`, prevent the drag.
+          if (ok === false) {
+            cur_target = false;
+          }
+        }
+
+        this._target = cur_target;
       }
 
+      return this;
+    },
+
+    _clearTarget : function() {
+      var target = this._target;
+      var ghost = this.getGhostNode();
+
+      if (target !== false) {
+        JX.DOM.remove(ghost);
+      }
+
+      this._target = false;
+      return this;
+    },
+
+    _getCurrentTarget : function(p) {
       var ghost = this.getGhostNode();
       var target = this._target;
       var targets = this._targets;
       var dragging = this._dragging;
-      var origin = this._origin;
-
-      var p = JX.$V(e);
-
-      // Compute the size and position of the drop target indicator, because we
-      // need to update our static position computations to account for it.
 
       var adjust_h = JX.Vector.getDim(ghost).y;
       var adjust_y = JX.$V(ghost).y;
@@ -187,11 +287,16 @@
         // Don't choose the dragged row or its predecessor as targets.
 
         cur_target = targets[ii].item;
-        if (cur_target == dragging) {
-          cur_target = false;
-        }
-        if (targets[ii - 1] && targets[ii - 1].item == dragging) {
-          cur_target = false;
+        if (!dragging) {
+          // If the item on the cursor isn't from this list, it can't be
+          // dropped onto itself or its predecessor in this list.
+        } else {
+          if (cur_target == dragging) {
+            cur_target = false;
+          }
+          if (targets[ii - 1] && targets[ii - 1].item == dragging) {
+            cur_target = false;
+          }
         }
 
         break;
@@ -199,41 +304,51 @@
 
       // If the dragged row is the first row, don't allow it to be dragged
       // into the first position, since this operation doesn't make sense.
-      if (cur_target === null) {
+      if (dragging && cur_target === null) {
         var first_item = targets[targets.length - 1].item;
         if (dragging === first_item) {
           cur_target = false;
         }
       }
 
-      // If we've selected a new target, update the UI to show where we're
-      // going to drop the row.
+      return cur_target;
+    },
 
-      if (cur_target !== target) {
+    _onmove : function(e) {
+      if (!this._dragging) {
+        return;
+      }
 
-        if (target !== false) {
-          JX.DOM.remove(ghost);
-        }
+      var p = JX.$V(e);
 
-        if (cur_target !== false) {
-          var ok = this.getGhostHandler()(ghost, cur_target);
-          // If the handler returns explicit `false`, prevent the drag.
-          if (ok === false) {
-            cur_target = false;
-          }
-        }
+      var group = this._group;
+      var target_list = this._getTargetList(p);
 
-        target = cur_target;
+      var ghost = this.getGhostNode();
+      var target = this._target;
+      var targets = this._targets;
+      var dragging = this._dragging;
+      var origin = this._origin;
 
-        if (target !== false) {
+      // Compute the size and position of the drop target indicator, because we
+      // need to update our static position computations to account for it.
 
-          // If we've changed where the ghost node is, update the adjustments
-          // so we accurately reflect document state when we tweak things below.
-          // This avoids a flash of bad state as the mouse is dragged upward
-          // across the document.
+      var adjust_h = JX.Vector.getDim(ghost).y;
+      var adjust_y = JX.$V(ghost).y;
+
+      var cur_target = false;
+      if (target_list) {
+        cur_target = target_list._getCurrentTarget(p);
+      }
+
+      // If we've selected a new target, update the UI to show where we're
+      // going to drop the row.
 
-          adjust_h = JX.Vector.getDim(ghost).y;
-          adjust_y = JX.$V(ghost).y;
+      for (var ii = 0; ii < group.length; ii++) {
+        if (group[ii] == target_list) {
+          group[ii]._setTarget(cur_target);
+        } else {
+          group[ii]._clearTarget();
         }
       }
 
@@ -241,16 +356,25 @@
       // adjust the cursor position for the change in node document position.
       // Do this before choosing a new target to avoid a flash of nonsense.
 
-      if (target !== false) {
+      adjust_h = 0;
+      adjust_y = 0;
+      if (this._target !== false) {
+        adjust_h = JX.Vector.getDim(ghost).y;
+        adjust_y = JX.$V(ghost).y;
+
         if (adjust_y <= origin.y) {
           p.y -= adjust_h;
         }
       }
 
-      p.x = 0;
+      if (this._canDragX()) {
+        p.x -= origin.x;
+      } else {
+        p.x = 0;
+      }
+
       p.y -= origin.y;
       p.setPos(dragging);
-      this._target = target;
 
       e.kill();
     },
@@ -276,6 +400,12 @@
         this.invoke('didCancelDrag', dragging);
       }
 
+      var group = this._group;
+      for (var ii = 0; ii < group.length; ii++) {
+        JX.DOM.alterClass(group[ii].getRootNode(), 'drag-target-list', false);
+        group[ii]._clearTarget();
+      }
+
       if (!this.invoke('didEndDrag', dragging).getPrevented()) {
         JX.DOM.alterClass(dragging, 'drag-dragging', false);
       }