Skip to content

Kanban — collaborative real-time board (Phase 4 showcase)

The flagship demo of Fitz LiveViews. A three-column kanban board (To Do / In Progress / Done) with card creation, movement, inline title editing, and deletion — every action broadcast in real time to every open browser, DOM state preserved across renders.

Source: examples/kanban/

Run

cd examples/kanban
fitz run

Open http://127.0.0.1:3000/ in two browser windows side by side. Type a title and your name, hit Add Card. The card appears in both windows within milliseconds. Use to move forward, to move back, × to delete. Click edit on any card to open its inline editor — the toggle is per card_id, so opening one card's editor does not disturb any other card.

Why this example matters

The counter proves reactivity works. The chat proves broadcast + forms work. The kanban proves the whole stack scales to a serious app in ~475 lines of Fitz — no bundler, no framework, no ORM, no external services — and shows how @live_component slots into an existing LiveView without rewriting the parent handler.

It exercises every Phase 1 → Phase 4 feature:

  • HTML primitives (html, flv, h_join, h_when)
  • LiveView layout with the full HTML document + viewport meta
  • Shared state via top-level let (Fitz F17 = Arc<Mutex>)
  • Broadcast via ws.broadcast(...)
  • Forms with data-flv-submit and data-flv-clear
  • data-flv-value-* for click payloads
  • Server-side HTML diff engine → compact patches over the wire
  • @live_component("card_editor") — per-card edit UI with independent state (is_editing + text buffer), one instance per card_id, dispatched via dispatch_component_events(frame)
  • Client-side patch application with outerHTML fallback
  • Responsive by default (grid → stacked below 768px)

The whole source

// Fitz LiveViews - collaborative kanban board (Phase 8.6 full SFC
// migration - Board is now an SFC).
//
// Post Fitz core v0.21.0 + K-4 (post-v0.21.0, 2026-07-17), the
// Board's render + state + create/move/delete events all live in
// `Board.fitzv`. `main.fitz` shrinks to just the HTTP GET + WS
// routes that wire the component into the framework - the
// ChatRoom pattern (Phase 8.4) at full-page scale.
//
// Architecture:
//   - `card.fitz`           - shared types (Card, Board).
//   - `board_helpers.fitz`  - pure helpers imported by the SFC.
//   - `Board.fitzv`         - SFC (state + events + template).
//   - `main.fitz`           - HTTP + WS wire-up (this file).
//
// KNOWN LIMITATION, MVP scope: `<CardEditor />` composition inside
// `{#for c in cards}` (dynamic-child composition inside a template
// loop) is Phase 11.7+ scope. This migration drops the inline edit
// feature; cards stay title-immutable post-creation.
//
// Run:   fitz run
// Open http://127.0.0.1:3000/ in two browser windows. Add cards,
// move them between columns, delete them. Every action broadcasts
// to every other window with a compact patch - no full HTML
// replace, no lost focus in the form.

from fitz_liveviews import Html, html, live_layout, html_response, LiveFrame, diff_html, component, dispatch_component_events, flv_register
from fitz_liveviews.ui.theme import ui_theme
from card import Card, Board
from Board import Board_render, Board_create_card, Board_move_right, Board_move_left, Board_delete_card


// -----------------------------------------------------------------------------
// Boot registration is IMPLICIT (Fitz core v0.21.0+)
// -----------------------------------------------------------------------------
//
// The compiler auto-generates `flv_register("Board", Board { },
// Board_render, {...})` from the metadata that `@live_component`/
// `@render_for`/`@on` leave in the imported `Board.fitzv` module
// (Phase 11.6.e §9.bb cross-module auto-inject). The `from Board
// import ...` line above brings the required names into scope.


// -----------------------------------------------------------------------------
// Kanban CSS (kept in the classic-Fitz side so we can escape braces
// once; the SFC template stays clean of style noise)
// -----------------------------------------------------------------------------

// Layout-only CSS. The packaged Card / Input / Button primitives (styled
// by `ui_theme()`'s --flv-* tokens) now carry the card surface, form
// field and submit-button styling that used to live here — this block
// only positions the board grid, the columns and the per-card action
// buttons (the one bit of hand-rolled markup, since those buttons carry
// a `data-flv-value-card_id` the Button primitive doesn't model).
let KANBAN_CSS: Str = """  * \{ box-sizing: border-box; \}
  #kanban-app \{
    max-width: 1200px;
    margin: 0 auto;
    padding: 1rem;
    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    color: var(--flv-text, #222);
  \}
  #kanban-app h1 \{
    margin: 0 0 1rem 0;
    font-size: 1.5rem;
    color: #CE412B;
  \}
  #kanban-app .sub \{
    color: var(--flv-text-muted, #666);
    font-size: 0.9rem;
    margin-bottom: 1rem;
  \}
  .add-form \{
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1.5rem;
    flex-wrap: wrap;
    align-items: flex-end;
  \}
  .add-form > * \{ flex: 1 1 200px; min-width: 0; \}
  .add-form > .flv-btn-slot \{ flex: 0 0 auto; \}
  .board \{
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1rem;
  \}
  .column \{
    background: var(--flv-surface-2, #f4f4f4);
    border-radius: var(--flv-radius-md, 8px);
    padding: 1rem;
    min-height: 200px;
  \}
  .column h2 \{
    margin: 0 0 1rem 0;
    font-size: 1rem;
    color: var(--flv-text, #333);
    text-transform: uppercase;
    letter-spacing: 0.05em;
  \}
  .column .count \{ color: var(--flv-text-muted, #888); font-weight: normal; \}
  .card-list \{
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  \}
  .card-author \{
    font-size: 0.85rem;
    color: var(--flv-text-muted, #666);
  \}
  .card-actions \{
    display: flex;
    gap: 0.25rem;
    justify-content: flex-end;
    margin-top: 0.5rem;
  \}
  .card-btn \{
    border: none;
    padding: 0.3rem 0.5rem;
    border-radius: var(--flv-radius-md, 6px);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    font-size: 1rem;
    background: var(--flv-surface-2, #ececef);
    color: var(--flv-text, #333);
  \}
  .card-btn:hover \{ filter: brightness(0.95); \}
  .card-btn-del \{ color: var(--flv-color-danger, #c62828); \}
  @media (max-width: 768px) \{
    .board \{ grid-template-columns: 1fr; \}
    #kanban-app \{ padding: 0.5rem; \}
  \}"""

fn kanban_shell(inner_html: Str) -> Html {
  let css = KANBAN_CSS
  return html("{ui_theme().raw}<style>{css}</style>{inner_html}")
}


// -----------------------------------------------------------------------------
// Server-side snapshot for diffing
// -----------------------------------------------------------------------------
//
// Every WS broadcast diffs against this to produce the compact
// patch list. Shared (like the component state) because every
// broadcast is derived from the canonical server state, not from
// any individual connection.

let last_board_html: Str = ""


// -----------------------------------------------------------------------------
// HTTP GET - initial render + client runtime
// -----------------------------------------------------------------------------

@get("/")
fn kanban_page() -> Response {
  let initial = component("Board", "main")
  let shelled = kanban_shell(initial.raw)
  return html_response(live_layout("/live/kanban", "kanban-app", shelled))
}


// -----------------------------------------------------------------------------
// WebSocket - event/patch cycle (ZERO event branches)
// -----------------------------------------------------------------------------
//
// The parent handler has ZERO event branches - every event is a
// component event routed via `dispatch_component_events(frame)`.

@ws("/live/kanban")
async fn kanban_socket(ws: WsConn<LiveFrame>) {
  loop {
    let frame = ws.recv()?
    if (last_board_html == "") {
      last_board_html = component("Board", "main").raw
    }
    let _handled = dispatch_component_events(frame)
    let new_html = component("Board", "main").raw
    let patches = diff_html(last_board_html, new_html)
    ws.broadcast(LiveFrame { html: new_html, patches: patches })?
    last_board_html = new_html
  }
}


// -----------------------------------------------------------------------------
// Server
// -----------------------------------------------------------------------------

@server(3000)
fn main() => 0

Anatomy — the four-way showcase

1. Shared state via top-level let

let board: Board = Board { cards: [], next_id: 1 }
let last_board_html: Str = ""

Two top-level lets. Every WebSocket handler sees the same values, mutations are safely serialized by Arc<Mutex> under the hood. No Redis, no pub-sub library.

2. @live_component for per-card state isolation

The CardEditor state and its three handlers are a self-contained unit. Since Fitz core v0.20.1, the compiler auto-registers the component from the decorators — no manual boot call needed:

@live_component("card_editor")
type CardEditor { is_editing: Bool = false, text: Str = "" }

// The compiler auto-generates the equivalent of:
//
//   flv_register(
//     "card_editor",
//     CardEditor {},  // uses type defaults
//     card_editor_render,
//     { "start": card_editor_start,
//       "cancel": card_editor_cancel,
//       "save": card_editor_save,
//     }
//   )
//
// from the `@render_for("card_editor")` and `@on("card_editor", ...)`
// decorators, appended to the top-level program before the HTTP
// server starts.

The parent embeds an instance per card:

let editor_instance = component("card_editor", "editor-{c.id}")

Two cards → two independent state entries in the framework's store. Opening editor for card 5 leaves card 3 untouched.

3. Payloads with data-flv-value-*

The card action buttons need to tell the server WHICH card they refer to. The data-flv-value-<key> convention auto-serializes into the event payload:

<button data-flv-click="move_right" data-flv-value-card_id="42"></button>

For clicks inside a component wrapper, the client also enriches the payload with component_name and instance_id by walking up to the closest [data-flv-component-name] element. That is what dispatch_component_events reads.

4. Component ↔ parent cooperation on save

The card_editor component owns the UI state (open/closed + edit buffer). The Board's card titles are owned by the parent. On save, both need to update. The pattern:

let handled = dispatch_component_events(frame)
if (handled) {
  // Component updated its own UI state.
  if (frame.event == "save") {
    // Parent applies the corresponding data change.
    board.cards = board.cards.map(fn(c) => apply_title_edit(card_id, new_text, c))
  }
} else {
  // Parent-only events: create_card, move_left, move_right, delete_card.
}

Neither the component reaches into Board, nor does the parent poke inside the component's state. They cooperate purely through the save event's payload. See docs/components.md for the full walkthrough.

5. Diff patches doing minimum work

When a card moves, the diff engine emits the smallest possible patch list — usually one remove in the source column plus one append in the destination. The rest of the DOM is untouched:

  • The create form keeps its focus, its half-typed input value, and its cursor position
  • Cards in other columns do not re-mount
  • Open editors in other cards stay open
  • No flash, no scroll jump, no relayout beyond the actual change

Compare this to a naive innerHTML = newHtml on the whole app — that would re-mount every card, blow away the form and any open editor, and cause a visible flicker.

6. Responsive by default

Grid layout with a media query:

.board { display: grid; grid-template-columns: repeat(3, 1fr); }
@media (max-width: 768px) {
  .board { grid-template-columns: 1fr; }
}

Three columns on desktop. One stacked column on mobile. The edit form's flex-wrap: wrap lets the input + Save + Cancel stack cleanly at narrow widths. Test with browser DevTools at 320px viewport.

The KANBAN_CSS constant trick

Fitz templates interpolate {expr} inside strings, so the { and } used by CSS collide with Fitz syntax. Two workarounds:

  • Escape every brace inline (\{ and \}) — tedious to scatter
  • Move the CSS to a let constant with the escapes done once — clean

We do the second. See KANBAN_CSS in the source. Same trick can be used for any embedded <style> block.

Known limitations

  • No drag & drop — buttons instead. Deferred to Phase 4.x polish.
  • No persistence — restart the server, board resets. ORM integration is separate.
  • No auth — anyone can create / move / delete / edit anyone's card.
  • Concurrent create race — two users creating simultaneously may briefly collide on next_id. A proper UUID would fix it.
  • Card order within a column is insertion order — no manual reordering within a column yet.