Skip to content

Dashboard — reusable metric tiles (Phase 4 showcase)

Six identical metric tiles rendered from a TileConfig list. Each tile is an independent instance of the same @live_component("metric_tile") — pressing +1 or reset on one tile updates only that tile's state, and every open browser sees the patch via broadcast.

Source: examples/dashboard/

Why this example exists

The kanban shows what @live_component looks like when one component instance lives inside a larger app (the card editor). This example shows what it looks like when many instances of the same component sit next to each other — the "duplicate the widget N times without duplicating the logic" case that composable UI frameworks are supposed to shine at.

The parent WS handler has zero explicit event branches. Every event is a component event (bump or reset scoped to one tile). dispatch_component_events(frame) handles the routing; the parent just re-renders and broadcasts.

Run

cd examples/dashboard
fitz run

Open http://127.0.0.1:3000/ in two browser windows. Click +1 on the "Users Signed Up" tile a few times. Only that tile's count increments — in both windows, at the same time. Click reset on another tile — that other tile clears independently.

The whole source

// Fitz LiveViews — dashboard of metric tiles (Phase 4 showcase).
//
// Run:   fitz run
// Open http://127.0.0.1:3000/ in two browser windows. Each tile is
// an independent instance of the same `@live_component("metric_tile")`
// — pressing "+1" or "reset" on one tile updates ONLY that tile's
// state, and every open window sees the patch via broadcast.
//
// Showcases:
//   - **Component reuse**: one `metric_tile` component definition,
//     six instances with per-instance state, all keyed by
//     `component("metric_tile", "<instance_id>")`.
//   - **Composition**: the parent supplies the per-tile label and
//     color via a wrapping card; the component supplies the counter
//     and buttons. Concerns split cleanly.
//   - **Broadcast + diff**: patches are computed against the entire
//     dashboard so one tile update sends a compact set of text
//     patches, not a full HTML replace.
//   - **Responsive**: 3-col grid on desktop → 2-col on tablet →
//     stacked on mobile, using CSS Grid `auto-fill` with `minmax`.

from fitz_liveviews import Html, html, flv, live_layout, html_response, LiveFrame, h_join, diff_html, component, dispatch_component_events, flv_register
from fitz_liveviews.ui.Card import card, card_render
from fitz_liveviews.ui.theme import ui_theme
from MetricTile import MetricTile, MetricTile_render, MetricTile_bump, MetricTile_reset


// -----------------------------------------------------------------------------
// Tile configuration (per-tile styling supplied by the parent)
// -----------------------------------------------------------------------------
//
// The component owns per-instance STATE (count). The parent owns
// per-tile CONFIG (label + accent color). They compose cleanly: the
// tile is wrapped in a styled card that carries the label and accent
// class; the component fills in the counter body.

type TileConfig {
  id: Str          // becomes the component instance_id
  label: Str
  accent: Str      // css class suffix: "blue" | "green" | "purple" | "orange" | "red" | "teal"
}

let TILES: List<TileConfig> = [
  TileConfig { id: "signups", label: "Users Signed Up", accent: "blue" },
  TileConfig { id: "revenue", label: "Revenue Events", accent: "green" },
  TileConfig { id: "errors",  label: "Errors Logged",  accent: "red" },
  TileConfig { id: "deploys", label: "Deploys Today",  accent: "purple" },
  TileConfig { id: "orders",  label: "Orders Placed",  accent: "orange" },
  TileConfig { id: "sla",     label: "SLA Uptime %",   accent: "teal" }
]


// -----------------------------------------------------------------------------
// Render
// -----------------------------------------------------------------------------

fn render_tile(tile: TileConfig) -> Html {
  let inner = component("MetricTile", tile.id)
  // The packaged Card primitive supplies the surface, border, radius and
  // shadow; the parent only adds the per-tile accent stripe + label. The
  // metric body (icon + count + badge + buttons) comes from MetricTile.
  let body = card_render(card { title: tile.label, body: inner.raw, elevation: "sm" })
  return html("""<div class="tile-accent tile-{tile.accent}">{body.raw}</div>""")
}

// Layout-only CSS. The packaged Card + Button primitives (styled by
// `ui_theme()`'s --flv-* tokens) carry the surface, borders, shadows and
// button styling that used to live here — this block now only positions
// the grid, the per-tile accent stripe and the metric body. Literal
// `{`/`}` in CSS are escaped as `\{`/`\}` so Fitz doesn't read them as
// interpolation delimiters.
let DASHBOARD_CSS: Str = """  * \{ box-sizing: border-box; \}
  #dashboard-app \{
    max-width: 1200px;
    margin: 0 auto;
    padding: 1rem;
    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    color: var(--flv-text, #222);
  \}
  #dashboard-app h1 \{
    margin: 0 0 0.25rem 0;
    font-size: 1.5rem;
    color: #CE412B;
  \}
  #dashboard-app .sub \{
    color: var(--flv-text-muted, #666);
    font-size: 0.9rem;
    margin-bottom: 1.5rem;
  \}
  .tile-grid \{
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 1rem;
  \}
  .tile-accent \{
    border-top: 4px solid #ccc;
    border-radius: var(--flv-radius-md, 8px);
    overflow: hidden;
  \}
  .tile-blue \{ border-top-color: #3b82f6; \}
  .tile-green \{ border-top-color: #10b981; \}
  .tile-red \{ border-top-color: #ef4444; \}
  .tile-purple \{ border-top-color: #8b5cf6; \}
  .tile-orange \{ border-top-color: #f97316; \}
  .tile-teal \{ border-top-color: #14b8a6; \}
  .tile-body \{
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
  \}
  .tile-top \{
    display: flex;
    align-items: center;
    justify-content: space-between;
    color: var(--flv-text-muted, #888);
  \}
  .tile-ico \{ font-size: 1.25rem; \}
  .tile-count \{
    font-size: 2.5rem;
    font-weight: 700;
    line-height: 1;
    color: var(--flv-text, #222);
  \}
  .tile-buttons \{
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
  \}
  @media (max-width: 480px) \{
    .tile-grid \{ grid-template-columns: 1fr; \}
    #dashboard-app \{ padding: 0.5rem; \}
    .tile-count \{ font-size: 2rem; \}
  \}"""

let last_dashboard_html: Str = ""

fn render_dashboard() -> Html {
  let tile_items = TILES.map(fn(t) => render_tile(t))
  let css = DASHBOARD_CSS
  return html("""<div id="dashboard-app">
{ui_theme().raw}
<style>
{css}
</style>
  <h1>Fitz LiveViews Dashboard</h1>
  <p class="sub">Each tile is an independent instance of the same <code>@live_component("metric_tile")</code>. Click <em>+1</em> or <em>reset</em> — only that tile's state changes, and every open window sees the patch.</p>
  <div class="tile-grid">
    {h_join(tile_items)}
  </div>
</div>""")
}


// -----------------------------------------------------------------------------
// Boot — the compiler auto-injects `flv_register("MetricTile", ...)` from
// the metadata in `MetricTile.fitzv` (Fitz core v0.21.0+ Phase 11.6.e §9.bb
// cross-module `@live_component` auto-inject). The `from MetricTile import
// MetricTile, MetricTile_render, MetricTile_bump, MetricTile_reset` line
// above brings the required names into scope; the compiler validates them
// in check-time and errors with an actionable hint if any is missing.
// -----------------------------------------------------------------------------


// -----------------------------------------------------------------------------
// HTTP GET
// -----------------------------------------------------------------------------

@get("/")
fn dashboard_page() -> Response {
  return html_response(live_layout("/live/dashboard", "dashboard-app", render_dashboard()))
}


// -----------------------------------------------------------------------------
// WebSocket handler
// -----------------------------------------------------------------------------
//
// The parent handler has ZERO event branches — every event is a
// component event (`bump` / `reset` scoped to one tile). If a frame
// arrives without component context, we just ignore it and re-broadcast
// the current state.

@ws("/live/dashboard")
async fn dashboard_socket(ws: WsConn<LiveFrame>) {
  loop {
    let frame = ws.recv()?
    if (last_dashboard_html == "") {
      last_dashboard_html = render_dashboard().raw
    }
    // Route to component; ignore uncontracted frames.
    let _handled = dispatch_component_events(frame)
    let new_html = render_dashboard().raw
    let patches = diff_html(last_dashboard_html, new_html)
    ws.broadcast(LiveFrame { html: new_html, patches: patches })?
    last_dashboard_html = new_html
  }
}


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

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

Anatomy — the composition pattern

The component owns per-instance state (the counter). The parent owns per-tile config (label + accent color). They compose via a wrapping card:

fn render_tile(tile: TileConfig) -> Html {
  let inner = component("metric_tile", tile.id)
  return html("""<article class="tile tile-{tile.accent}">
    <header class="tile-header">
      <h3 class="tile-label">{flv(tile.label)}</h3>
    </header>
    {inner.raw}
  </article>""")
}
  • The <article> is parent-controlled. It carries the label and the accent class.
  • {inner.raw} inlines the component's rendered body (counter + buttons + the framework's wrapper div with data-flv-component-name and data-flv-value-instance_id).
  • Click events from inside {inner.raw} walk up to the framework's wrapper, pick up component_name + instance_id, and route via dispatch_component_events(frame) to the correct tile's state.

The zero-branch WS handler

@ws("/live/dashboard")
async fn dashboard_socket(ws: WsConn<LiveFrame>) {
  loop {
    let frame = ws.recv()?
    if (last_dashboard_html == "") {
      last_dashboard_html = render_dashboard().raw
    }
    let _handled = dispatch_component_events(frame)
    let new_html = render_dashboard().raw
    let patches = diff_html(last_dashboard_html, new_html)
    ws.broadcast(LiveFrame { html: new_html, patches: patches })?
    last_dashboard_html = new_html
  }
}

Compare this to a hand-written version without components: you would need six pairs of if (frame.event == "bump_signups") / if (frame.event == "bump_revenue") branches, or one branch keyed on frame.payload["tile_id"] — either way you would end up hand-rolling a per-tile state store. dispatch_component_events collapses all that boilerplate.

Responsive by default

The tile grid uses grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)). Wide viewports get 4-5 columns; medium ones get 2-3; a phone at 375px gets 1. A media query below 480px stacks everything and shrinks the counter font size. Verify in DevTools with the iPhone SE viewport.

Full component walkthrough

For the API surface (@live_component / @render_for / @on / flv_register / component / dispatch_component_events), the canonical @ws loop pattern, gotchas of gradual typing at the framework boundary, and what the framework does not yet provide, see docs/components.md.

Known limitations

  • Per-tile config is parent-supplied. Today the label and accent live outside the component. If we wanted the component to own them too, we would need per-instance seed data. Not in Phase 4 MVP.
  • No persistence. Every restart resets the store. Wire up Fitz's ORM if you need this.
  • No bulk actions (e.g., "reset all"). Would need a dispatch_to_all(name, event, payload) helper — not in scope for Phase 4 MVP.