Examples

# Example: Project board (the kanban flagship)

One shared board, three lanes. A move is one reply — the row animates out of its lane, into the next, and every count badge repaints — live in every open tab, with no Stimulus controller and no hand-picked Turbo target.

## Try it

A real kanban. Move a card with **← →** and watch it animate out of one lane and into the next (pick a style — `random` is the fun one). Rename a card inline and press **Enter** — the morph keeps your caret. **×** archives through a confirm gate and hides the card optimistically. Open this page in a second tab: every move, add, rename, and archive arrives there live, count badges included.

Simulate network latency

Delay every action by 700 ms so the loading states are visible.

Move a card and watch it animate — in every open tab.

default

fade

slide

scale

highlight

shake

random

### To do

1

- ×→ doing

Add

### Doing

0

Add

### Done

1

- ×← doing

Add

```ruby
# frozen_string_literal: true

# The Project board (issue #216) — the kanban flagship. Three fixed lanes on
# ONE shared public board; each card is its own nested reactive root (see
# BoardCardComponent) owning move/rename/archive, while the board owns the
# per-lane composers and the effect-style picker (signed state, threaded into
# every row so per-call effects use the visitor's pick).
#
# Count badges are kept live EVERYWHERE via reactive:js text ops — the actor's
# reply chains them (reply.js) and every mutation broadcasts them — closing
# the gap where a peer tab's badges go stale after a row broadcast. Empty
# lanes are pure CSS (the lane list's :empty pseudo shows the placeholder), so
# the 0↔1 boundary needs no bookkeeping on either path.
class ProjectBoardComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component
  include Phlex::Rails::Helpers::TurboStreamFrom

  STYLES = %w[default fade slide scale highlight shake random].freeze
  LANE_TITLES = { 'todo' => 'To do', 'doing' => 'Doing', 'done' => 'Done' }.freeze

  # The lane list's :empty placeholder — rendered by CSS the moment the last
  # card leaves, restored the moment one arrives; no server bookkeeping.
  EMPTY_LANE_CSS = "empty:before:content-['No_cards_yet'] empty:before:block " \
                   'empty:before:rounded-field empty:before:border empty:before:border-dashed ' \
                   'empty:before:border-base-300 empty:before:py-4 empty:before:text-center ' \
                   'empty:before:text-xs empty:before:opacity-50'

  reactive_state :style

  # Composer inputs are named PER LANE (title_todo/…): the client's field
  # sweep is root-wide, so three same-named inputs would collide (last wins);
  # the lane param picks the right one out of the declared trio.
  action :add_card, params: { title_todo: :string, title_doing: :string, title_done: :string, lane: :string }
  action :set_style, params: { style: :string }

  def initialize(style: 'default')
    @style = STYLES.include?(style) ? style : 'default'
  end

  def id = 'project-board'

  class << self
    def lane_target(lane) = "lane-#{lane}-cards"

    # One js chain repainting all three count badges from server truth —
    # chained onto the actor's reply AND broadcast to peers after every
    # mutation, so no tab's badges ever go stale.
    def count_ops
      Card::LANES.reduce(Phlex::Reactive::JS.new) do |chain, lane|
        chain.text("#lane-#{lane}-count", Card.by_lane(lane).count)
      end
    end

    def broadcast_counts(exclude:)
      broadcast_to(*Card.stream_key, js: count_ops, exclude:)
    end
  end

  # Add into the named lane. The full-board re-render (the todo-list
  # precedent) clears the composer and repaints counts in one go; peers get
  # the row append + the count sync.
  def add_card(lane:, title_todo: '', title_doing: '', title_done: '')
    return reply.replace unless Card::LANES.include?(lane)

    title = { 'todo' => title_todo, 'doing' => title_doing, 'done' => title_done }.fetch(lane).to_s.strip
    return reply.replace.flash(:error, 'A card needs a title', dismiss_after: 2500) if title.blank?

    card = Card.create!(title:, lane:, position: Card.next_position(lane))
    BoardCardComponent.broadcast_to(*Card.stream_key, append: card,
                                                      target: self.class.lane_target(lane),
                                                      exclude: reactive_connection_id)
    self.class.broadcast_counts(exclude: reactive_connection_id)
    reply.replace
  end

  # The effect-style picker: whitelisted, signed into state, and threaded into
  # every row on the re-render so subsequent moves animate with the pick.
  def set_style(style:)
    @style = style if STYLES.include?(style)
    reply.replace
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-4', data: { testid: 'project-board' })) do
      turbo_stream_from(*Card.stream_key) # cross-tab sync
      # The flash landing zone (Phlex::Reactive.flash_target default) — the
      # archive toast and the blank-title error append here.
      div(id: 'flash', class: 'flex flex-col gap-1', data: { testid: 'flash' })
      header_row
      div(class: 'grid gap-4 sm:grid-cols-3') do
        Card::LANES.each { |lane| lane_column(lane) }
      end
    end
  end

  private

  def header_row
    div(class: 'flex flex-wrap items-center justify-between gap-2') do
      span(class: 'text-sm opacity-70') { 'Move a card and watch it animate — in every open tab.' }
      style_picker
    end
  end

  def style_picker
    div(class: 'join', data: { testid: 'style-picker' }) do
      STYLES.each do |name|
        button(**mix(on(:set_style, style: name),
                     class: "join-item btn btn-xs #{'btn-active' if name == @style}",
                     data: { testid: "style-#{name}" })) { name }
      end
    end
  end

  def lane_column(lane)
    div(class: 'flex flex-col gap-3 rounded-box border border-base-300 bg-base-200/50 p-3',
        data: { testid: "lane-#{lane}" }) do
      div(class: 'flex items-center justify-between') do
        h3(class: 'text-sm font-semibold') { LANE_TITLES.fetch(lane) }
        span(id: "lane-#{lane}-count", class: 'badge badge-sm', data: { testid: "count-#{lane}" }) do
          Card.by_lane(lane).count.to_s
        end
      end
      ul(id: self.class.lane_target(lane), class: "flex flex-col gap-2 #{EMPTY_LANE_CSS}") do
        Card.by_lane(lane).each { |card| render BoardCardComponent.new(card:, style: @style) }
      end
      composer(lane)
    end
  end

  def composer(lane)
    div(class: 'flex gap-2') do
      input(**mix(on(:add_card, lane:, event: 'keydown.enter'),
                  name: "title_#{lane}", placeholder: 'Add a card…', autocomplete: 'off',
                  class: 'input input-sm input-bordered min-w-0 flex-1',
                  data: { testid: "new-card-#{lane}" }))
      button(**mix(on(:add_card, lane:, busy: '…'),
                   class: 'btn btn-sm btn-primary', data: { testid: "add-#{lane}" })) { 'Add' }
    end
  end
end
```

## Every feature, in one board

| Feature | Where |
| --- | --- |
| **Effects** — declared enter/exit/update on the row, per-call override from the style picker | the move / the picker |
| **Nested reactive roots** — each card is its own root inside the board (its rename input stays its own) | the row |
| **Record + state identity** — the card's GlobalID AND the picked style in one signed token | the row |
| **`reply.remove(...).stream(...).js(...)`** — one reply moves the card and repaints every badge | `move` |
| **Live peer counts** — `broadcast_to(js:)` text ops keep every tab's badges honest | every mutation |
| **Inline rename + morph** — Enter saves; focus and caret survive | the row |
| **`confirm:` + optimistic hide** — archive gates, then hides in the same gesture | the row |
| **Per-lane composers** — `busy:` dedupes, a blank title gets an error flash | the board |
| **CSS `:empty` lanes** — the placeholder needs zero bookkeeping on either path | the lane list |

## The move — one reply, three streams

A move is the whole mental model in one action: the row removes itself (its **exit effect runs before the element leaves**), a fresh-token render appends into the new lane (wearing its **enter effect**), and a `reactive:js` text-op stream repaints all three count badges from server truth. The same delta broadcasts to peers with the actor's echo excluded:

```ruby
def move(to:)
  return reply.morph unless Card::LANES.include?(to) && to != @card.lane

  @card.update!(lane: to, position: Card.next_position(to))
  broadcast_move
  reply.remove(effect: picked_effect)
       .stream(self.class.append(target: ProjectBoardComponent.lane_target(to),
                                 model: @card, style: @style, effect: picked_effect))
       .js(ProjectBoardComponent.count_ops)
end
```

`picked_effect` is the style picker's choice, signed into the row's own state — `default` defers to the row's declared effects, anything else overrides **per call** (the issue #215 three-level precedence, live).

## Why each card is its own reactive root

The card carries a rename `<input name="title">`. Field collection is **root-wide**, so if the rows were tokenless (the team-inbox pattern) every card's title would land in the board's sweep and the last one would win. Making each card a **nested reactive root** (issue #15) gives every row its own field scope, its own signed identity — the Card's GlobalID plus the picked style — and its own `move`/`rename`/`archive` actions. The board keeps only what is genuinely board-level: the composers and the style picker.

## Live counts in every tab

Row broadcasts alone leave a peer's count badges stale — the collection bookkeeping in a reply is the **actor's** HTTP response. The board closes that gap with one `reactive:js` chain repainting all three badges from server truth, chained onto the actor's reply (`reply.js`) AND broadcast to peers after every mutation:

```ruby
def self.count_ops
  Card::LANES.reduce(Phlex::Reactive::JS.new) do |chain, lane|
    chain.text("#lane-#{lane}-count", Card.by_lane(lane).count)
  end
end

def self.broadcast_counts(exclude:)
  broadcast_to(*Card.stream_key, js: count_ops, exclude:)
end
```

Empty lanes need no bookkeeping at all: the lane list styles its own `:empty` pseudo-class, so the placeholder appears the moment the last card leaves — on the actor's reply and the peer's broadcast alike.

## Notes

- The board is **one shared instance** for every visitor — that's what makes the second-tab story visible between strangers. Seeds restore a starting set on boot.
- Moves are deliberately **not optimistic**: the exit/enter animations are the actor's feedback, and an optimistic hide would hide them. `busy: '…'` dedupes a double-click instead. Archive IS optimistic — the confirm gate already slowed it down.
- Cross-tab delivery runs on whatever transport the app has — Action Cable here, [pgbus](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus) in production — the component code is identical.
- The composer inputs are named per lane (`title_todo`, …): the client's field sweep is root-wide, and three same-named inputs would collide.