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¶
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-submitanddata-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+textbuffer), one instance per card_id, dispatched viadispatch_component_events(frame)- Client-side patch application with
outerHTMLfallback - 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¶
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:
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:
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
letconstant 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.