Drag-and-Drop the Rails Way: Derived Order, REST Drops, Morph Reconciliation

Drag-and-Drop the Rails Way: Derived Order, REST Drops, Morph Reconciliation

A capstone synthesis: Fizzy's whole drag-and-drop interaction is built with almost no bespoke code because every layer defers to a convention the next layer understands — a derived sort order with no position column, each drop modeled as an ordinary REST resource so the routing table (not a controller branch) owns the meaning, a rich transactional model verb shared by two entry points, a server-rendered URL with an __id__ placeholder that carries the entire contract, and an optimistic move reconciled by morph and kept honest by a single SQL sort axis. The Rails-side ideas are load-bearing; the drag's JavaScript is background mechanism. Synthesizes P1, P2, P6, P5, and F4b.

Derived order with no position column (last_active_at as the one sort axis) State-change-as-REST-resource: the routing table owns the case-on-destination Thin drop controllers (3-7 lines) calling one rich model verb triage_into / close / postpone as transactional model verbs with two entry points URL-as-contract: __id__ placeholder swap; the route decides what a drop means Optimistic DOM move reconciled by morph (the to_key handshake, new mechanism) Top-or-bottom data attribute constrains the client's guess to the server's sort axis Config-as-data in data-* attributes; JS stays domain-agnostic verb-as-noun / CRUD-on-a-noun (STYLE.md as written house law) count the edge cases this line absorbs for free fizzy by nityeshagarwal

You're building a Kanban board. A user grabs a card out of "Triage," drags it across three columns, and drops it into "In Progress." Somewhere down the line they'll drag another card onto "Done," and a third onto "Not Now." It's the single most tactile interaction in the whole product — and it's the one that looks like it has to be a pile of bespoke JavaScript: track the pointer, compute the insertion index, renumber every card's position, post a JSON payload describing the move, reconcile the server's answer against the optimistic DOM you already mutated.

That's the version you'd brace yourself to write. This tutorial is about the version 37signals actually shipped, where almost none of that exists — not because they were clever about the drag, but because every layer defers to a convention the next layer already understands. The drag is the thinnest possible client gesture sitting on top of a stack of Rails conventions that were already there for other reasons: a derived sort order with no column to update, a state change modeled as an ordinary REST resource, a rich model verb that owns the transaction, a URL that carries the entire meaning of the drop, and morph reconciliation closing the loop. Six conventions, and the drag falls through them.

This is a capstone. It doesn't introduce a new principle — it shows five of them collaborating on one feature, the way [[see C1: The Line Where Saving Becomes a Product]] does for sending a message. The Rails-side ideas are load-bearing; the drag's JavaScript is background mechanism, and we'll be explicit every time we touch it about what it accomplishes so the lesson stays where it belongs: on the Rails seam underneath.

Let's start, as always, with the version you'd vibe-code first.

The naive version: a position column and a controller full of branches

You reach for the obvious model first. A card has a column and a position within it, so you store both:

class Card < ApplicationRecord
  belongs_to :column
  # position: integer  ← the order within the column
end

Then the drop has to renumber. The card landed third in its new column, so everything from the third slot down shifts by one — and the column it left has a hole where it used to be, so those shift up. You write the controller that owns all of it:

class CardsController < ApplicationController
  def update
    @card = Card.find(params[:id])
    destination = params[:destination]   # "column", "done", "not_now", "maybe"...

    case destination
    when "column"
      target = Column.find(params[:column_id])
      Card.where(column: @card.column).where("position > ?", @card.position).update_all("position = position - 1")
      Card.where(column: target).where("position >= ?", params[:position]).update_all("position = position + 1")
      @card.update!(column: target, position: params[:position])
    when "done"
      @card.update!(closed_at: Time.current, column: nil)
    when "not_now"
      @card.update!(postponed_at: Time.current, column: nil)
    when "maybe"
      @card.update!(column: nil)   # back to triage
    end

    render turbo_stream: turbo_stream.replace(@card, partial: "cards/card", locals: { card: @card })
  end
end

Look at everything wrong here, none of which will raise. The position renumbering is two update_alls that have to agree, and the day a drop fails halfway you've got a column with two cards claiming slot 3 — a corrupted order with no error. The case destination is a junction box: the controller has become the one place that knows what every kind of drop means, so every new drop target (a "Blocked" column, an "Archive") grows another when. And the response is a destructive replace — it rips out the card node and drops in a fresh one, throwing away the CSS transition the browser was mid-animation on and any menu the user had open on that card. So you'd reach for client-side bookkeeping to restore all of it, and now you're maintaining a reconciliation layer by hand, in a language this series keeps at arm's length.

The naive shape is a matrix: a stored order you renumber by hand, times a controller that branches on destination, times a destructive swap you then have to repair on the client.

A clean technical teaching poster, square 1:1 canvas, off-white (#FAF7F2) groun…

Beat 1 — Derived order: there is no position column to update

Open the real cards table and look for the column you were about to add (schema.rb:218-234):

create_table "cards", id: :uuid, force: :cascade do |t|
  t.uuid     "account_id", null: false
  t.uuid     "board_id",   null: false
  t.uuid     "column_id"                          # which column — nullable (triage = none)
  t.datetime "created_at", null: false
  t.uuid     "creator_id", null: false
  t.date     "due_on"
  t.datetime "last_active_at", null: false        # ← the sort axis
  t.bigint   "number",     null: false
  t.string   "status",     default: "drafted", null: false
  t.string   "title"
  t.datetime "updated_at", null: false
  t.index ["account_id", "last_active_at", "status"], ...
  t.index ["column_id"], ...
end

There is no position. There is no rank, no sort_order, no acts_as_list ranking machinery. A card's place in a column isn't stored anywhere — it's derived from last_active_at, and the order falls out of one scope (card.rb:24):

scope :latest, -> { order last_active_at: :desc, id: :desc }

That's the entire ordering system. The most recently active card sorts to the top; ties break on id. When you drag a card into a column, you don't renumber anything, because there is no number — the act of moving it bumps its activity, and the next render simply re-sorts. This is derive, don't store doing load-bearing work, and we don't re-derive the principle here — that's [[see P2: Derive, Don't Store]]'s job. The point for this feature: the naive version's entire position-renumbering subsystem — the two update_alls, the half-failed-drop corruption, the reconciliation cron you'd eventually write to fix drift — doesn't exist, because there's no stored answer to keep in sync. Reorder has nothing to update.

There's one deliberate exception, and it sharpens the rule rather than breaking it. A "golden" (pinned) card should always float to the top regardless of activity. Fizzy doesn't store that as a position either — it derives it from the existence of a satellite row (card/golden.rb:8):

scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL").preload(:goldness) }

A golden card is one that has a goldness row; the prepend_order sorts rows that lack one (id IS NULL → true → sorts after) below those that have one, so golden cards lead — still no integer to maintain, the pin is derived from a row's presence.

"If order is just last_active_at, doesn't dragging a card to a specific spot between two others become impossible? Real Kanban lets you place a card exactly." It does — and Fizzy made a product decision that order is recency, not hand-placement. That's the trade: by refusing arbitrary positions, they delete the entire ranking subsystem. (When position genuinely encodes user intent — reordering whole columns, not cards — Fizzy does store it, and even then reorder is a REST resource, not a renumbering loop; that's the left_position/right_position pair in [[see P6: Model Every State Change as CRUD on a Noun]].) The judgment is explicit: derive when it's computable, store only when it's genuine intent. Card order is computable.

The contrast: the naive version stored a position integer and owned its consistency forever — two update_alls per drop, corruption on a half-failure, a cron to sweep drift. Fizzy stores no order at all. The one SQL sort axis (last_active_at desc, id desc) is the only truth, and a drag just changes activity. The whole renumbering layer is absent, not optimized.

Beat 2 — State-change-as-REST-resource: the meaning lives in the routing table

Now the drop itself. In the naive controller, the meaning of a drop — close it? postpone it? move it? send it back to triage? — lived in a case destination branch. Fizzy has no such branch anywhere, because each kind of drop is its own noun, and the routing table — not a controller conditional — is where the case-on-destination lives (routes.rb:64-75):

namespace :columns do
  resources :cards do
    scope module: :cards do
      namespace :drops do
        resource :not_now      # drop onto "Not Now"  → postpone
        resource :stream       # drop onto "Maybe?"   → send back to triage
        resource :closure      # drop onto "Done"     → close
        resource :column       # drop into a column   → triage_into
      end
    end
  end
end

Four destinations, four resources, zero verbs you invented. Dropping a card onto "Done" is POST to its closure resource — creating a closure. Dropping onto "Not Now" is creating a not_now. Dropping into a column is creating a column drop. This is verb-as-noun and we don't re-derive it — [[see P6: Model Every State Change as CRUD on a Noun]] owns the principle, and Fizzy wrote it down as law: "When an action doesn't map cleanly to a standard CRUD verb, we introduce a new resource rather than adding custom actions" (STYLE.md:138-153).

What that buys you is controllers that have nothing left to decide. Here is the entire controller for dropping a card into a column (columns/cards/drops/columns_controller.rb):

class Columns::Cards::Drops::ColumnsController < ApplicationController
  include CardScoped

  def create
    @column = @card.board.columns.find(params[:column_id])
    @card.triage_into(@column)
  end
end

And dropping onto "Done" (columns/cards/drops/closures_controller.rb):

class Columns::Cards::Drops::ClosuresController < ApplicationController
  include CardScoped

  def create
    @card.close
  end
end

And "Not Now" (columns/cards/drops/not_nows_controller.rb):

class Columns::Cards::Drops::NotNowsController < ApplicationController
  include CardScoped

  def create
    @card.postpone
  end
end

And back to "Maybe?" / triage (columns/cards/drops/streams_controller.rb):

class Columns::Cards::Drops::StreamsController < ApplicationController
  include CardScoped

  def create
    @card.send_back_to_triage
    set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first
  end
end

Three to seven lines each. No case. No if destination ==. The branch the naive controller carried got split into the URL space — there are four routes, so there are four controllers, so the destination is decided by which route the request hit, before any Ruby runs. Each controller does exactly one thing because it can only ever be reached for one kind of drop. (Notice include CardScoped on every one: the card is loaded through the user's accessible boards, so a drop on a card you can't reach 404s before create runs — that's [[see P3: Security Is the Shape of Your Data Access]], the same card_scoped.rb shape across all 21 of Fizzy's card controllers.)

"Four near-identical three-line controllers feels like duplication. Wouldn't one controller with a case be DRYer?" It would be shorter, not DRYer. The case version concentrates four unrelated decisions into one method that every future drop type has to be edited into — the controller becomes a contended file, and a bug in the "close" branch ships inside the same method as "postpone." Four resources means four files that each change for exactly one reason, and adding a fifth drop target is adding a route and a file, never editing an existing one. The routing table absorbs the branch the controller would otherwise grow — count the edge cases that namespace :drops block deletes: every when, forever.

The contrast: the naive controller owned a case destination junction box — one method that knew the meaning of every drop and grew a branch per new target. Fizzy owns four routes and four tiny controllers; the destination is decided by the URL the request arrived on, so no controller ever branches on it, and a new drop type is a new noun, not a new when.

Beat 3 — triage_into: one rich model verb, two entry points

Look back at the column-drop controller — @card.triage_into(@column). That one call is the whole move, and the controller knows nothing about what it entails. The intelligence lives in the model, in an intention-revealing transactional verb (card/triageable.rb:19-27):

def triage_into(column)
  raise "The column must belong to the card board" unless board == column.board

  transaction do
    resume
    update! column: column
    track_event "triaged", particulars: { column: column.name }
  end
end

Read it as a sentence: triaging a card into a column means — make sure the column belongs to this board, then, atomically: resume it if it was postponed, set its column, and record the event. Three consequences that must succeed or fail together, wrapped in one transaction. The controller doesn't orchestrate them; it states the intention and the model owns the consequence. We don't re-derive that worldview — [[see P1: The Model Is the Truth]] owns it. What this beat adds is the sharp extra lesson: two entry points, one verb.

A card moves into a column two ways. The user can drag it there (the ColumnsController#create above). Or the user can click a column button on the card's own face — and that path lands in a different controller, the explicit triage resource. Both call triage_into. The guard ("column must belong to the board"), the resume-if-postponed, the event tracking — all of it lives once, in the model verb, so the two entry points cannot drift. If the logic lived in the controller, the drag path and the click path would each carry their own copy, and the day someone fixes a bug in one, the other still has it.

The same shape governs the other drops: close (card/closeable.rb:31-39) and postpone (card/postponable.rb:31-39) are each a single transactional verb the controllers merely name. postpone, for instance, sends the card back to triage, reopens it, clears any activity spike, and creates the not_now row — four steps, one atomic verb — and both the drag-onto-Not-Now path and the hourly auto-postpone job call the identical method.

The contrast: the naive update inlined the consequences of each move into the controller — @card.update!(closed_at: ...) here, a renumber there — so every entry point to the same state change reimplemented it, and they drifted. Fizzy's controllers call one named verb (triage_into, close, postpone) that owns the transaction; the logic lives once and every entry point — drag, click, scheduled job — reaches the same truth.

Beat 4 — The URL carries the contract: the routing table decides what a drop means, not the JS

Here is the hinge of the whole tutorial, and it's where the "almost no bespoke code" claim finally pays off. You have one drag controller on the client. It handles every drop — onto Done, onto Not Now, into a column, back to Maybe. So how does one generic drop() function know to close a card here and postpone it there?

It doesn't. It never decides. Each column, when the server renders it, is handed the URL that its drops should POST to — and that URL is the meaning. The board renders four kinds of column, each with a different drop_url (the four _*.html.erb partials under boards/show/):

<%# boards/show/_closed.html.erb:1  — the "Done" column %>
<%= column_tag id: "closed-cards", name: "Done",
      drop_url: columns_card_drops_closure_path("__id__"), ... %>

<%# boards/show/_not_now.html.erb:1  — the "Not Now" column %>
<%= column_tag id: "not-now", name: "Not Now",
      drop_url: columns_card_drops_not_now_path("__id__"), ... %>

<%# boards/show/_stream.html.erb:1  — the "Maybe?" / triage column %>
<%= column_tag id: "maybe", name: "Maybe?",
      drop_url: columns_card_drops_stream_path("__id__"), ... %>

<%# boards/show/_column.html.erb:1  — a real workflow column %>
<%= column_tag id: dom_id(column), name: column.name,
      drop_url: columns_card_drops_column_path("__id__", column_id: column.id), ... %>

Each column_tag stamps that URL onto the column as a plain data attribute (columns_helper.rb:18-25):

data = {
  drag_and_drop_target: "container",
  navigable_list_target: "item",
  column_name: name,
  drag_and_drop_url: drop_url,                       # ← the contract, on the DOM node
  drag_and_drop_css_variable_name: "--card-color",
  drag_and_drop_css_variable_value: card_color
}.merge(data)

Now look at what the drag controller does when a card is dropped — and notice how little it knows (drag_and_drop_controller.js:138-143):

async #submitDropRequest(item, container) {
  const body = new FormData()
  const id  = item.dataset.id
  const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id)   // fill in the card

  return post(url, { body, headers: { Accept: "text/vnd.turbo-stream.html" } })
}

That's the entire server interaction. It reads the destination column's drag-and-drop-url, swaps the __id__ placeholder for the dragged card's id, and POSTs an empty body. It does not know whether this closes, postpones, or moves the card. The __id__ placeholder is the seam: the server rendered a URL with a hole in it, the client fills the hole with the card it's moving, and the routing table — Beat 2's four resources — does the rest. The same drop() serves Done, Not Now, Maybe, and every column, decided entirely by which URL the server stamped on the column it was dropped into.

This is configuration carried as data on the wire, and it's why the JavaScript stays domain-agnostic and tiny. The client invents no notion of "close" or "postpone"; it transports the card id to an address the server chose. Change what dropping onto "Done" means and you change a route, not a line of JavaScript.

A clean technical teaching poster, square 1:1 canvas, off-white (#FAF7F2) groun…

"Why a __id__ placeholder string instead of building the URL in JavaScript from the card id?" Because building the URL on the client means the client has to know the route shape/columns/cards/:id/drops/closure — and now your Rails routes and your JavaScript both encode the same path, kept in sync by hand. The placeholder keeps URL construction where it belongs: the server, which owns routes.rb, renders the finished path with one hole; the client only knows "put the card id here." The route shape lives in exactly one place. That's the wire carrying intent as data rather than the client reconstructing server knowledge — the same instinct as [[see P5: One Renderer, HTML Over the Wire]].

The contrast: the naive client builds a JSON payload { destination: "done", card_id: ... } and a controller branches on destination — the meaning of the drop is split between client (which names it) and server (which switches on it), kept in sync by hand. Fizzy's client names nothing; it POSTs to a server-rendered URL whose route is the meaning. The routing table is the single source of truth for what every drop does.

Beat 5 — Optimistic move, then morph reconciliation

Drag should feel instant — the card has to land under the user's cursor the moment they let go, not after a server round-trip. So the client moves it optimistically: it drops the card node into its new column in the DOM right away, before the server has answered. Watch the order in drop() (drag_and_drop_controller.js:40-53):

async drop(event) {
  const targetContainer = this.#containerContaining(event.target)

  if (!targetContainer || targetContainer === this.sourceContainer) { return }

  this.wasDropped = true
  this.#increaseCounter(targetContainer)
  this.#decreaseCounter(this.sourceContainer)

  const sourceContainer = this.sourceContainer
  this.#insertDraggedItem(targetContainer, this.dragItem)        // ← optimistic: move it NOW
  await this.#submitDropRequest(this.dragItem, targetContainer)  // ← then tell the server
  this.#reloadSourceFrame(sourceContainer)
}

The card is already in its new column (line 50) before the POST goes out (line 51). The user sees an instant move. Now the server has to confirm it without ripping out the node the user is looking at — so the drop reply morphs the column instead of replacing it (columns/cards/drops/columns/create.turbo_stream.erb:1):

<%= turbo_stream.replace(dom_id(@column), partial: "boards/show/column", method: :morph, locals: { column: @column }) %>

The morph mechanic itself — how method: :morph diffs old and new DOM and edits only what differs — lives in [[see F4b: Turbo 8 — Morphing & Live Refresh]] and we don't re-derive it here. The only drag-specific point: because the client already moved the card to where the server sorts it, the card is in the same place in both trees, so the morph reply is a near-no-op for it — placement, focus, and in-flight animation survive untouched. The optimistic move isn't undone-and-redone; it's reconciled, and when the guess matched the truth (it almost always does) the confirmation changes nothing.

"If the server is going to re-render and morph the column anyway, why bother moving the card optimistically at all? Just wait for the server." Because the round-trip has latency, and a card that snaps into place 150ms after you drop it feels broken. The optimistic move buys instant feedback; the morph buys correctness without cost — because when the optimistic guess is right, morph changes nothing, so the two together give you instant and correct with no reconciliation code. The whole reason it works is Beat 6: the client's guess is constrained so it can't disagree with the server.

Beat 6 — Top-or-bottom: the client's guess honors the one SQL sort axis

For the optimistic move to be a no-op under morph, the client's guess about where the card lands has to match what the server will render. If the client guessed "third from the top" and the server sorts it first, morph would have real work to do — it'd shuffle the card, and the user would see a flicker as the optimistic placement got corrected. So the client's guess can't be free. It's constrained to honor the same one axis the server sorts on.

Recall Beat 1: the server's only sort is last_active_at desc, with golden cards floated to the top. A freshly-dropped card just became the most recently active, so it sorts to the top of its column — unless it's golden, in which case it's already in the golden band at the very top. That's a binary: a card goes either at the top (if it's golden) or, well, also effectively at the top of the non-golden cards. The server encodes that one bit onto the card as a data attribute (cards_helper.rb:10):

def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block)
  # ...
  data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed?
  # ...
end

data-drag-and-drop-top is present on golden cards and absent otherwise. The client reads exactly that one bit to decide where to insert the dragged card (drag_and_drop_controller.js:122-136):

#insertDraggedItem(container, item) {
  const itemContainer = container.querySelector("[data-drag-drop-item-container]")
  const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]")
  const firstTopItem = topItems[0]
  const lastTopItem  = topItems[topItems.length - 1]

  const isTopItem = item.hasAttribute("data-drag-and-drop-top")
  const referenceItem = isTopItem ? firstTopItem : lastTopItem

  if (referenceItem) {
    referenceItem[isTopItem ? "before" : "after"](item)   // golden → before the golden band; normal → after it
  } else {
    itemContainer.prepend(item)
  }
}

The client has exactly one decision: is this card golden (top-banded) or not? If golden, place it before the existing top-band cards; if not, place it after them — i.e. at the top of the normal cards. That's the same partition the server's with_golden_first + latest produces. The client isn't guessing a position out of thin air; it's reading the server's own data-drag-and-drop-top flag and honoring the single SQL sort axis. So the optimistic placement cannot disagree with server truth — there's only one axis to agree on, and the server told the client which side of it this card falls on. That's why the Beat 5 morph is reliably a no-op: the guess is constrained by the same convention the server sorts by.

The contrast: the naive client computes an insertion index from the pointer's pixel position and posts it as params[:position], and the server trusts it — so the client and server each have their own idea of order, and they drift the instant a sort rule changes on one side. Fizzy's client reads one server-stamped bit (data-drag-and-drop-top) and places the card on the correct side of the one sort axis. There's no position to disagree about, so the optimistic move and the server render can't contradict each other.

The whole journey, on one screen

Trace one drag — a card pulled from a column and dropped onto "Done":

  1. Render. The board renders the "Done" column via boards/show/_closed.html.erb, which calls column_tag with drop_url: columns_card_drops_closure_path("__id__"). The helper stamps that URL onto the column as data-drag-and-drop-url. Each card carries data-drag-and-drop-top iff it's golden (cards_helper.rb:10).
  2. Grab and drop. The generic drag_and_drop_controller moves the card optimistically into the "Done" column (drop(), line 50) — placed by the one data-drag-and-drop-top bit (#insertDraggedItem, 122-136) so it lands where the server will sort it.
  3. POST. #submitDropRequest (138-143) reads the column's URL, swaps __id__ → the card id, and POSTs an empty body. The client never named "close" — it just hit the closure URL.
  4. Route → controller. routes.rb:64-75 sends that POST to Columns::Cards::Drops::ClosuresController#create, which is three lines: @card.close. The destination's meaning came from the route, not a branch.
  5. Model verb. close (card/closeable.rb:31-39) runs one transaction: destroy any not_now, create the closure, track the event. The card's order needs no update — there's no position column; last_active_at is the only axis (schema.rb:218-234, card.rb:24).
  6. Morph reply. The controller renders a turbo_stream.replace ... method: :morph, which morphs the "Done" column over the client's optimistic version. The dropped card is already in place in both trees, so morph changes nothing for it — focus, transition, placement all survive. Reconciled, not redone.

Six conventions, and the drag fell through all of them.

A large dense 'whole journey on one screen' technical master diagram, square 1:…

Which principles this serves

This capstone is five principles collaborating; each has its deep home elsewhere, and here you watched them interlock on one feature:

  • [[see P2: Derive, Don't Store]] — card order is derived from last_active_at, so there's no position to renumber; golden-first is derived from a row's existence.
  • [[see P6: Model Every State Change as CRUD on a Noun]] — every drop is create on a noun (closure, not_now, column, stream), so the routing table owns the destination branch the controller would otherwise grow; written as law in STYLE.md:138-153.
  • [[see P1: The Model Is the Truth]]triage_into/close/postpone are rich transactional verbs the thin controllers merely name, so two entry points share one implementation.
  • [[see P5: One Renderer, HTML Over the Wire]] — the __id__ URL carries intent as data, and the optimistic-then-morph handshake is the same shape as Campfire's to_key.
  • [[see F4b: Turbo 8 — Morphing & Live Refresh]] — morph reconciliation is the mechanism that makes the optimistic move a no-op when the client's guess was right.

Key Takeaways — Patterns to Steal

  • When a list needs ordering, the reflex is a position integer you renumber on every move — two update_alls that must agree, corruption on a half-failed drop, and eventually a cron to sweep the drift. Don't store the order if you can compute it. Fizzy's cards have no position column (schema.rb:218-234); order is one derived scope, order(last_active_at: :desc, id: :desc) (card.rb:24), so a drag just bumps activity and the next render re-sorts. The entire renumbering subsystem is absent, not optimized — and "pinned to top" is likewise derived from a row's existence via with_golden_first (card/golden.rb:8), never an integer.

  • When one gesture can mean several things (close, postpone, move, un-triage), the reflex is one controller with a case destination. Make each meaning a noun instead, and let the routing table own the branch. Fizzy models four drop destinations as four REST resources — resource :not_now, :stream, :closure, :column (routes.rb:64-75) — so there are four tiny controllers (@card.close, @card.postpone, @card.triage_into(@column), each 3-7 lines), the destination is decided by which route the POST hit, and a new drop target is a new noun, never a new when. It's written as house law: introduce a resource rather than a custom action (STYLE.md:138-153).

  • A state change reachable two ways (dragged and clicked) will be implemented twice and drift, if the logic lives in the controller. Put it in one intention-revealing transactional model verb both paths call. Fizzy's triage_into (card/triageable.rb:19-27) guards, resumes, sets the column, and tracks the event in one transaction; both the column-drop controller and the explicit triage button call it, so the guard and the event can't diverge — and close/postpone follow the identical shape, shared with their scheduled-job callers.

  • A generic client gesture should never decide what it means. Have the server render a URL with a hole in it and let the route carry the meaning. Each Fizzy column is stamped with a drag-and-drop-url holding a __id__ placeholder (columns_helper.rb:18-25); the one drag controller swaps the card id into the hole and POSTs (drag_and_drop_controller.js:138-143), knowing nothing about close-vs-postpone-vs-move. The same drop() serves every destination, decided entirely by which URL the server stamped — so changing what a drop does is a route change, not a line of JavaScript, and the route shape lives in exactly one place.

  • An instant-feeling drag must move the card optimistically before the server answers — but confirming that move with a destructive replace rips out the node mid-animation and flickers. Reply with method: :morph instead, so the server's render is reconciled against the optimistic DOM, not swapped in. Fizzy moves the card in the DOM first, then POSTs (drag_and_drop_controller.js:40-53), and the drop response is turbo_stream.replace(dom_id(@column), ..., method: :morph) (columns/cards/drops/columns/create.turbo_stream.erb:1) — when the client's guess matched the truth, morph finds nothing to change and leaves placement, focus, and transition untouched. It's the same optimistic handshake as Campfire's to_key, reached through morph.

  • For the optimistic placement to morph cleanly, the client's guess about where the card lands must honor the same axis the server sorts on — otherwise morph corrects it with a visible flicker. Constrain the client to the one sort axis by stamping the decision as a data attribute. The server sets data-drag-and-drop-top only on golden cards (cards_helper.rb:10); the client reads exactly that bit to insert above or below the top band (drag_and_drop_controller.js:122-136) — the same partition with_golden_first + latest produce server-side. There's only one axis to agree on, and the server told the client which side this card falls on, so the guess can't disagree with truth.