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¶
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 withdata-flv-component-nameanddata-flv-value-instance_id).- Click events from inside
{inner.raw}walk up to the framework's wrapper, pick upcomponent_name+instance_id, and route viadispatch_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.