Visualization
Choose a visualization block based on the data or relationship you want to show:
- a single number (an SLO, a total, a current value) →
@block/stat - a trend over time or any X/Y relationship →
@block/plot - the rows themselves, with detail and drill-in →
@block/table - part-of-whole breakdowns →
@block/arc(aspie/donut/sunburst) or@block/treemap - relationships between entities →
@block/graph - a trace or span timeline →
@block/waterfall - a change timeline of detection events (alerts, change points, deploys) →
@block/timeline - a single record’s fields, as label · value pairs →
@block/kv - an agent conversation →
@block/session - the workflows configured beside a frame, with their run history →
@block/workflows - a bounded ratio like utilisation →
@block/progress
Each block reads an expression, usually an @expr/query, and maps columns to visual properties by name. See the block reference for each block’s options.
Panels
Every visualization block renders inside a panel by default. The panel has a heading, optional description, border, and padding. Set panel props directly on the visualization: title, description, aside, gap, width, height, padding, and chrome. Do not wrap it in @block/panel.
{ "@block/table": { "from": { "query": "SELECT service, p95 FROM latency" }, "title": "Latency by service", "description": "p95 over the selected range" }}description adds a @block/tooltip icon beside the title. The text appears on hover or focus. The icon also appears with "chrome": false when the block has a title. Wrap non-panel blocks in @block/tooltip to add an explanation.
Set "chrome": false to drop the card surface — no background, no border, and no inner padding — when embedding a chart inside another block (a sparkline in a cell, a chart beside text). The heading still renders if you authored one; a block with no heading props and "chrome": false is the bare visualization.
A table can use a tagged query ({ "query": … }) for from. The table adds sorting and pagination to that query before sending it to the database. An @expr/query runs first and paginates the full result in memory. Use it for modest results. A view-table name in from behaves like a tagged query and inherits the current window and filters.
A visualization accepts an optional id: a stable, unique name within the view. Use letters, digits, _, or -; the first character must be a letter or _. The runtime uses the id in block status and error reports. It works with "chrome": false. Set it on visualizations you want to identify:
{ "@block/table": { "id": "latency_by_service", "from": { "query": "SELECT service, p95 FROM latency" } }}Use a stable id that does not depend on query data.
For @block/plot, the mark is the block’s input, so id and the panel props live inside it (next to scales) — whichever mark it is:
{ "@block/plot": { "line_y": { "id": "latency_over_time", "from": "spans", "y": "avg(Duration)" } }}The same holds for a multi plot — they go inside multi, alongside its shared from and scales:
{ "@block/plot": { "multi": { "id": "latency_percentiles", "title": "Latency", "from": "spans", "marks": [{ "line_y": { "y": "p50" } }, { "line_y": { "y": "p95" } }] } }}@block/arc works the same way: the shape (pie / donut / sunburst) is the block’s input, so id and the panel props live inside that tag.
Empty states
Every visualization has a default message for an empty result. Set empty to provide a message specific to the panel:
{ "@block/plot": { "line_y": { "from": "spans", "y": "avg(Duration)", "empty": "No traces from this service in the selected window." } }}empty can also contain a block, such as a link to setup docs or a button that widens the time range. For example, use @block/stack to combine a message with an action:
{ "@block/treemap": { "from": "spans", "group": "ServiceName", "value": "count()", "empty": { "@block/stack": { "direction": "column", "items": [ { "@block/text": "No services are reporting yet." }, { "@block/link": { "to": { "url": "/docs/connect-data", "@block/text": "Connect a source" } } } ] } } }}Empty results are specific to each block: a table has no rows, an arc has no slices, a graph has no vertices, and a waterfall has no spans.
@block/stat
A single number, such as request rate, error budget, or p99 latency. A stat can also show a comparison or a sparkline.
The simplest form is a value on its own:
{ "@block/stat": { "title": "Requests", "value": { "@expr/query": "SELECT count() FROM requests" } }}Reading a column, and comparing
Set from to a query result and value to a column in that result. When the column is a pair, the stat shows its foreground value and the difference from its background value. @expr/paired_query returns this pair when you declare a background. A query can also select a tuple directly, for example CAST((now, before), 'Tuple(foreground Float64, background Float64)').
{ "@block/context": { "extend": { "totals": { "@expr/paired_query": { "from": "requests", "select": { "Requests": "count()" }, "resolution": "none", "background": { "timerange": { "transform": { "shift": "previous_period" } } } } } }, "@block/stat": { "title": "Requests", "from": "totals", "value": "Requests", "polarity": "positive", "delta": "relative", "label": "vs previous period" } }}polarity— which direction is good:positive(up, the default),negative(down, for error rates and latencies), orneutral(no judgement). It only tints the delta; it never changes the sign.delta—relative(a percentage, the default) orabsolute(the signed difference in the metric’s own format).label— the caption on the line under the number, after the delta. A pair doesn’t record what its background means, so say it here. A stat with no background shows the label alone, which suits a caption such as"summed over every live process".
A column that isn’t a pair simply renders as the number, with no delta — so a page-level toggle that resolves the background away degrades to a plain stat rather than erroring.
Sparkline
Give the paired query a resolution and it also draws a sparkline beside the number — with nothing extra to author. A bucketed paired query folds each metric’s whole-window reading and its per-bucket series into the one column, so the stat takes its headline from the former and its line from the latter:
{ "@block/context": { "extend": { "totals": { "@expr/paired_query": { "from": "requests", "select": { "Requests": "count()" }, "resolution": "low", "background": { "timerange": { "transform": { "shift": "previous_period" } } } } } }, "@block/stat": { "title": "Requests", "from": "totals", "value": "Requests", "label": "vs previous period" } }}series only tunes what’s already drawn: mark picks the shape (line, the default, area, or bar) — the background draws in the same shape, muted. A line or area background sits beneath the foreground; a bar background is grouped beside it instead — one pair of bars per bucket, background left — since two bar series at the same x would occlude each other. Author "series": false to read as a bare number instead — a KPI row where some tiles get a line and some don’t, all reading the one paired query. To draw a sparkline from a separate table (say a metric you didn’t put in the paired query), point series.from and series.value at it; series.x defaults to that table’s own time axis — the bucket a timeseries query grouped on — so a shifted background overlays the foreground without a join.
Several stats in one card
A stat is a card of its own by default: its title is the card heading and a sparkline takes half the tile. To put several readings in one card under a shared heading, set "compact": true on each stat and stack them in a @block/column with divider. A compact stat’s title is a small muted label close above the number, the delta and label share the line under the number, and the sparkline is a fixed-height strip beside them. Inside a card, a stat draws no card of its own, and a compact one also drops its padding, so it lines up with the card’s heading. compact takes an expression, so a density control can switch it.
A stat fills its slot by default, so the stats in a fixed-height card share its height. Set "height": "auto" to size each one to its content and keep them together at the top.
{ "@block/panel": { "title": "Are users happy?", "@block/column": { "gap": "sm", "divider": true, "items": [ { "@block/stat": { "title": "Page load p75", "compact": true, "height": "auto", "from": "page_loads", "value": "LoadP75", "polarity": "negative", "label": "vs previous" } }, { "@block/stat": { "title": "Live instances", "compact": true, "height": "auto", "from": "instances", "value": "Instances", "label": { "@expr/handlebars": "{{instances.RoleLabel}}" } } } ] } }}To keep a bar or a note with the stat it belongs to, nest the two in their own @block/column inside the divided one, so the rule falls between the groups.
@block/plot
The general-purpose chart, built on Observable Plot. A single chart is one mark — a tagged line_y / bar_y / bar_x / area_y / cell / dot / rect / rule_y / rule_x — that carries its own data (from), its channels (x, y, color, …), and its scales. Reach for a line over a @expr/timeseries_query for trends; a bar for categorical comparisons, adding fx to group bars side by side. To overlay several marks on one set of axes, wrap them in multi with a shared from/scales. Pair it with @block/crosshair to sync one tooltip across every chart on a time axis, and to brush-to-zoom; a categorical chart reads out on hover by itself (see Reading a chart on hover).
A single line over time:
{ "@block/plot": { "line_y": { "from": { "@expr/query": "SELECT _ts, count FROM requests" }, "x": "_ts", "y": "count" } }}What a channel means depends on the from. Over a view table the block builds the query itself, so a channel is a SQL scalar expression it puts in the SELECT ("y": "avg(DurationMs)"), selected value-as-name. Over an already-resolved table — a query expression, or a context key holding one — nothing is built: the channel is a column name read back off the result, so it has to be one of the names that query’s select produces — including the _ts / _time_bucket scalars, when the query selected them.
Either way the mark is bound to a column of the table that came back, by the channel’s own text. A name the table lacks binds to nothing, so rather than drawing an empty chart the mark fails with an error naming the channel and listing the columns its source does produce — the usual way in is editing a query’s select without updating the marks that read it. noemata validate --online renders your frames and reports these, so it catches them before anyone opens the page (a panel behind a tab or a @block/case needs a --case that reaches it).
A bar chart coloured by category, with a value-axis label:
{ "@block/plot": { "bar_y": { "from": { "@expr/query": "SELECT service, count FROM requests" }, "x": "service", "y": "count", "color": "service", "scales": { "y": { "label": "Requests" } } } }}"axis": "bars" on a bar_x’s band scale draws that axis’s labels on the bars themselves rather than at the frame’s edge. Each label sits at its bar’s value end — by default just outside it, moving onto the bar for the bars that run too close to the frame to leave room. "axis": { "bars": "prefer_inside" } inverts that: on the bar, moving out only when the text is wider than the bar itself. Either way a label is only ever missing the room it asked for, so a long tail stays readable. It’s the top-list shape — a bar_x sorted descending with names that would otherwise eat the left margin. Each label takes whichever of the page’s foreground / background contrasts better with the bar under it, over a soft halo of the other — so it survives a stack boundary changing colour mid-word, or a gridline running behind it. The axis still owns how its values read, so format applies; a band whose bars stack is labelled once, at the end of the stack, and a faceted bar labels each facet’s own bars. A chart too short to fit its labels at all — a compact one in a narrow panel — is read on hover instead. Horizontal bars only: a bar_y reads its bands along the bottom in room it already has, and a label written across a narrow column would truncate more often than not — so only a bar_x’s y (and a multi’s shared y) accepts the anchor, and the schema rejects it anywhere else.
{ "@block/plot": { "bar_x": { "from": { "@expr/query": "SELECT endpoint, requests FROM traffic ORDER BY requests DESC" }, "x": "requests", "y": "endpoint", "color": "endpoint", "scales": { "x": { "label": "Requests" }, "y": { "axis": "bars" }, "color": { "legend": false } } } }}Bars group side by side with fx (or fy on a bar_x), a facet channel that splits the frame into one subplot per value while every other scale stays shared. Put the group on fx and the series on x: the bars inside a group abut, and the fx band spaces the groups. The colour legend already names the series, so hide the repeated inner axis with "x": { "axis": false }.
Which channel you pick decides what you get. The one on the mark’s own domain axis groups — fx for bar_y, fy for bar_x — while the other splits the chart into small multiples, where each subplot is a whole bar chart. A bar_y split by fy is a stack of rows and follows the rules below; a bar_x split by fx keeps its inner axis per column. Setting both gives a grid of one per (fx × fy) pair. Every scale but the facet band stays shared, so the subplots are comparable however you slice them; past a handful of facets even a grouped chart reads as small multiples, since each group gets that much less width. Facet channels are accepted on bars, lines and areas.
{ "@block/plot": { "bar_y": { "from": { "@expr/query": "SELECT region, service, requests FROM traffic" }, "fx": "region", "x": "service", "y": "requests", "color": "service", "scales": { "x": { "axis": false }, "fx": { "label": "Region" } } } }}Stacking trends into rows
fy on a line_y, area_y or bar_y stacks one small multiple per value over a shared x axis — a trend per service, say — with each row labelled by its facet value. The rows share the y scale, so a row’s height is its magnitude against the others; the y axis is hidden by default because its ticks would repeat per row ("y": { "axis": true } brings it back), and the rows abut so an annotation spanning the stack reads as one rule or band ("fy": { "gap": 0.1 } spaces them). Without row_height the rows divide the block’s height between them; "fy": { "row_height": 48 } sizes the chart from its row count instead, so a long stack grows and scrolls rather than squashing. The crosshair reads one series per row, and a changes marker lands in the row of the series it detected. Annotations without a facet channel span every row, with their caption drawn once above the stack; an annotation with its own fy column stays in that row.
{ "@block/plot": { "line_y": { "from": { "@expr/timeseries_query": { "from": "spans", "select": "count()", "group_by": "ServiceName" } }, "y": "count()", "fy": "ServiceName", "changes": true, "scales": { "fy": { "row_height": 48 } } } }}Several marks sharing one set of axes via multi:
{ "@block/plot": { "multi": { "from": { "@expr/query": "SELECT _ts, p50, p95 FROM latency" }, "marks": [ { "line_y": { "y": "p50", "label": "p50" } }, { "line_y": { "y": "p95", "label": "p95" } } ] } }}A value channel takes a list instead of one column ("y": ["p50", "p95"]), folding it into one series per entry without restating the mark; label names them in order.
Channels, a mark’s where, and label all take an expression, so a chart can follow a control instead of being duplicated behind a @block/case. The expression sits at the whole position rather than just inside a list, so one chart can change how many series it draws, labels and all:
{ "line_y": { "from": { "@expr/timeseries_query": { "from": "metrics_sum", "select": { "@expr/case": { "mode": { "split": ["RxRate", "TxRate"], "default": ["TotalRate"] } } } } }, "y": { "@expr/case": { "mode": { "split": ["RxRate", "TxRate"], "default": "TotalRate" } } }, "label": { "@expr/case": { "mode": { "split": ["in", "out"], "default": [] } } } }}The same holds for the column-naming fields on @block/treemap, @block/arc, @block/graph, @block/waffle and @block/waterfall — a hierarchy or map can follow the same control as the chart beside it.
A channel is the SELECT item, so it must resolve before the query compiles — an expression there is read once per change rather than per row, and one that yields a table is rejected. Keep the select and the channel on the same choice, or alias the column once ("select": [{ "Value": { "@expr/case": … } }], "y": "Value") and let the channel stay fixed.
A cell grid coloured by a value is a heatmap. For the two common calendar shapes there’s a calendar mark that expands to a cell with the right defaults, so you supply only from, x, y, and color:
"interval": "week"— a GitHub-style contribution calendar: week columns (x) × weekday rows (y), one cell per day. It hides the week axis and labels the weekday rows."interval": "day"— a punch card: weekday columns (x) × hour-of-day rows (y), one cell per hour. It labels the weekday columns.
Both bin the colour into discrete swatches derived from the data (a quantile scale — no hand-picked boundaries) and fill their container by default; set aspect_ratio: 1 to square the cells instead (the chart then derives its height from its width, and centres itself in whatever box the layout gives it). Squaring needs a real step on both axes: a grid with a single x band has no x unit to match against, so pin that one with content_width (px, the plot’s own box) and let the height fill. cell_gap is the gutter between cells in pixels — uniform on both axes whatever the cell shape — and every default is overridable via scales. The weekday axis expects an ISO day-of-week number (1 = Mon … 7 = Sun), which the mark renders as a localized name.
{ "@block/plot": { "calendar": { "from": { "@expr/query": "SELECT week, dow, commits FROM contributions" }, "x": "week", "y": "dow", "color": "commits", "interval": "week" } }}A calendar is a cell; reach for cell directly when you need a heatmap that isn’t a calendar (e.g. a latency × time grid). The options the calendar sets are all plain cell options: aspect_ratio: 1 squares the cells (the chart then derives its height from its width), border_radius rounds their corners, and a binned color scale gives the discrete swatches. A colour scale bins a measure three ways: "type": "quantile" (n equal-count buckets, the calendar default) and "type": "quantize" (n equal-width buckets) both derive their breakpoints from the data — like a continuous scale reading its extent — while "type": "threshold" takes explicit boundaries in domain.
Gaps in a timeseries
A timeseries query returns rows only for the buckets it found data in, but the chart draws the whole grid: every bucket of the window the query ran over, at the width it bucketed by. What a mark shows in a bucket no row covers depends on its shape. A bar or an area reads it as 0, since nothing counted is zero. A line and a heatmap cell leave it empty, so the line breaks there and the cell stays blank. A mark split into series by color, z, or a facet fills each series’ own empty buckets, so a series with no rows in a bucket leaves the others where they are — including a bar grouped by fx over the bucket, whose empty groups keep their band.
Only a bucket the query returned nothing for is filled. A row it returned holding NULL is a reading it took and had no value for, which stays NULL and draws as a break.
Set fill on the mark to override what an empty bucket shows, with 0 or null: "fill": null on a bar leaves the empty buckets undrawn, and "fill": 0 on a line closes the break. It takes an expression, so one control can switch it. On a multi it sits in the shared data block and applies to every mark. Set it to null for a gauge or a level — a queue depth, a memory reading — where a bucket with no sample was not measured at zero. @block/stat’s series takes the same fill for its sparkline.
{ "@block/plot": { "bar_y": { "from": { "@expr/timeseries_query": { "from": "requests", "select": "count()" } }, "y": "count()", "fill": null } }}Only a bucketed source has a grid to complete. A chart over a plain query or an inline table draws exactly the rows it was given. The grid rides on the result’s own time context, so it survives being kept in a @block/context key, read by several marks, and shaped along the way — a mark’s where or limit narrows the rows that came back without narrowing the window the chart draws, so the buckets it removed are drawn as empty. Set fill on such a mark when that reads wrong. (A cell heatmap is the exception that fills without a time axis: its grid is every x × y pair its own rows carry, so a blank cell renders blank rather than at the bottom of the colour scale.)
The rows a query returns are never filled either, so a @block/table over the same query lists what came back; use @expr/densify when you need those rows dense. A query that returns nothing renders the empty state rather than a grid of zeroes.
Change annotations
Set changes on a series mark (line_y, area_y, bar_y) — the marks that carry a series the detector can scan — and the chart adds one external annotation that reads from an @expr/query wrapping @expr/changes. The wrapped query runs the detector over the mark’s own data (value = mark’s y, axis = mark’s x or the source’s time axis, series = the mark’s color / z column) and filters to actual change rows. The annotation renders each change as a vertical rule coloured by severity through the annotation_severity palette, with the change kind in the crosshair tooltip. true runs the detector at its defaults; an object passes its knobs through (kinds, min_magnitude, max_changes, min_segment_size, seasonality). Authored per mark — not on a multi (it would cascade indiscriminately) and not on a non-series mark. Change annotations stay out of the colour scale and the legend — they mark severity, not a series.
{ "@block/plot": { "line_y": { "from": { "@expr/timeseries_query": { "from": "requests", "select": { "as": { "Errors": "countIf(StatusCode >= 400)" } } } }, "y": "Errors", "changes": { "min_magnitude": 1 } } }}External annotations
Set annotations at the whole-plot position — on the single mark or on a multi — to draw external overlays atop the plot’s coordinate system. Each entry runs its own query (or none, when every channel is a constant) and produces one drawn annotation per row. There are three variants:
point— a vertical rule at each row’sx, with an optionallinestyle ({ literal: 'solid' | 'dashed' | 'dotted' }; a bare column name reads a per-row style).range— a tinted rect between each row’sx1andx2, spanning the plot’s height.fill_opacitydefaults to0.07.threshold— a horizontal rule at each y anatcomparator names. The comparator is the same shape a workflow threshold detector uses:{ above: y },{ below: y },{ between: [y1, y2] }, or{ outside: [y1, y2] }.breach_opacity(default0, off) tints the breach region on top.
The color slot admits either a raw channel spec (a { literal } constant, or a column of CSS colour strings for per-row colouring) or the palette shorthand { severity: … } — the inner value is an ExpressibleChannelSpec that resolves through the annotation_severity chart palette: { literal: 'critical' } pins one severity, and a bare column name ({ severity: 'severity' }) maps each row’s severity through the palette. label and detail are expressible strings for a caption near the mark and the hover-card body. Annotations piggyback on the plot’s own scales — they contribute nothing to scale inference or to the legend. Calendar plots do not accept annotations (no continuous time axis to overlay).
{ "@block/plot": { "line_y": { "from": { "@expr/timeseries_query": { "from": "requests", "select": { "as": { "P95": "quantile(0.95)(DurationMs)" } } } }, "y": "P95", "annotations": [ { "range": { "from": "incidents", "x1": "started_at", "x2": "resolved_at", "color": { "severity": "critical" }, "fill_opacity": 0.08 } }, { "threshold": { "at": { "above": 500 }, "color": { "severity": "warning" }, "label": "SLO 500 ms" } }, { "point": { "from": "deploys", "x": "deployed_at", "line": "dotted", "color": { "severity": "neutral" } } } ] } }}Reading a chart on hover
Hovering a heatmap reads out the whole hovered column — one row per band with its value, swatched in that cell’s colour and with the band under the pointer emphasized — so a latency × time grid shows the distribution at that instant rather than just the one cell. The measure is colour-encoded, so it has no axis to carry its format: the readout takes the format the coloured column declares, and scales.color’s own format ("percent", "duration", …) overrides it.
Hovering a horizontal bar (bar_x) reads out the whole band: the category as the header, one row per stack segment — swatched in the segment’s own colour, with the one under the pointer emphasized — and the band’s total in the footer. Anywhere in a band’s row hits it, including outside the drawn bar, so a tail bar a couple of pixels long is still readable. Values go through the value axis’s own format and the category through the band axis’s, exactly as their ticks read; a bar with no color column — or one coloured by its own band, where the category would only repeat the header — labels its single row after the value axis (scales.x.label, else the column). That label takes an expression, so a chart whose measure is picked at runtime names it in the readout rather than falling back to whatever the column happens to be called. Faceted charts read out per subplot, and this works on a compact chart — one that draws no axes at all — which is the case that needs it most.
There is no separate switch for it: it rides @block/crosshair’s enabled, the one setting for “charts answer the pointer”, so "enabled": false turns both off. Outside a crosshair scope the bar readout is still on — it is per-chart, with no shared cursor to sync — and top_n caps its segment rows the same way it caps the crosshair’s series rows.
Brushing a distribution
A drag across a chart commits every axis that declares a brush: a time axis sets the page’s time range, and an axis with "brush": { "filter": { "field": … } } adds a range predicate to the surrounding FilterContext — one live filter per axis, replaced by the next drag and removable as a pill in the filter bar. So on a latency heatmap, dragging a box picks both the window and the latency band to look at.
field names the view column to filter, which is rarely the column the axis plots: a heatmap’s y is usually a query-local bucket (roundDown(DurationMs, …)) that no view scalar binds, so the predicate targets DurationMs instead. Give the axis an explicit numeric domain and its bands are read as bucket lower bounds — selecting the 100ms and 300ms bands filters DurationMs >= 100 AND DurationMs < 1000, and the top band is open-ended. Without one the bands are plain values and the range is closed at both ends. Non-numeric bands can’t express a range, so they commit nothing.
Add tables whenever field isn’t a column on every table in the view. An unscoped filter goes into each table’s WHERE, so a traces-only DurationMs against a view that also carries logs fails to compile — and against a deliberately unscoped table (disable_auto_scope) it silently narrows data the panel should receive unfiltered.
{ "@block/plot": { "cell": { "from": { "@expr/timeseries_query": { "from": "traces", "select": [ { "as": { "Bucket": "roundDown(DurationMs, [0, 10, 100, 1000, 10000])", "Requests": "count()" } } ], "group_by": "Bucket" } }, "y": "Bucket", "color": "Requests", "scales": { "y": { "domain": [0, 10, 100, 1000, 10000], "reverse": true, "format": "duration", "brush": { "filter": { "field": "DurationMs", "tables": ["traces"] } } } } } }}Consistent colours across charts
By default each chart colours its categories independently, so the same value can land on different colours in two charts side by side. To pin a value to one colour across a frame, give the charts a shared share key on their colour scale. Every chart naming the same key pools the distinct values it colours by; each value is assigned a colour by its rank in the sorted union of them all — so a chart that only sees some of the values still colours the ones it has consistently with its siblings. It’s opt-in: charts without a share key keep colouring independently. share pins nothing itself, so it cannot be combined with an explicit range, domain, scheme, or { palette } — those pin the chart’s colours outright, which leaves the pooled assignment nowhere to land, so the pair is rejected rather than one silently winning. Sharing applies to categorical colours only; a chart colouring by a numeric measure keeps its continuous ramp. share sits under scales.color on every chart that has a colour scale — @block/plot, @block/arc, @block/treemap, @block/waffle, and @block/waterfall:
{ "@block/grid": { "cols": 2, "items": [ { "@block/plot": { "bar_y": { "from": { "@expr/query": "SELECT _ts, cost, model FROM usage" }, "y": "cost", "color": "model", "scales": { "color": { "share": "model" } } } } }, { "@block/arc": { "donut": { "from": { "@expr/query": "SELECT model, cost FROM usage" }, "group": "model", "value": "cost", "scales": { "color": { "share": "model" } } } } } ] }}Sharing is frame-scoped: a key pools charts within one frame rather than across frames.
Semantic palettes
When a colour channel carries a field with fixed, meaningful values — log severity, span outcome, HTTP status class — name a palette as the range instead of listing colours: "range": { "palette": "severity" }. Entries are matched to the domain by name (not position) and resolve to design-system tokens, so the colours follow the theme in light and dark. Omit domain to take the palette’s own entry order; author one only to restrict or reorder the series. The available palettes are severity, outcome, signal, http_status, and cache.
A palette matches its domain exactly — a value it doesn’t define is an authoring error. Normalize the field in SQL (lower(SeverityText), folding aliases like warning and crit onto the entry names) rather than passing the raw column. Palettes work on scales.color for every chart family: @block/plot, @block/arc, @block/treemap, @block/waffle, and @block/waterfall.
Stacking order
The stacking marks — area_y, bar_y, bar_x — lay their series out in the colour scale’s domain order, first entry at the baseline, so a stack reads the way its legend does. With no domain the order is the values’ own, which is what the legend shows anyway. Authoring one (or taking a { palette }’s entry order) sets both: a severity palette stacks fatal at the baseline and unspecified on top rather than alphabetically. A value the domain omits stacks after every value it lists.
@block/table
When the rows are the answer — a list of services, the slowest traces, recent errors — show a table. Point from at a query and you get a table for free; the interesting part is configuring it:
- Columns. Omit
columnsto show whatever the query returns, or list them to control order, headings, and formatting. A column can display a value ({ "title": ..., "value": ... }), a formatted value (add aformatlike"duration","bytes","percent"), or a Handlebarstemplatethat composes several fields into one cell.valueis a SQL value expression: when the result has a column by that name it reads that column, and anything else ("Bytes / 1024","concat(Host, ':', Port)") is computed by the database as part of the table’s query, so it sorts and filters like any other column. A computed column is keyed by itsid, which defaults to the expression text; set one to give it a readable name. A column’stitletakes an expression, so a heading can name whichever measure a control selected rather than being fixed. An optionalcolumnnames the data column behind the cell — the header sorts by it and the cell’s filter menu targets it (it defaults to the columnvaluedisplays; on a template or block column it is the only way to make the header interactive). - Displayed columns. Every table lets the user hide, show and reorder columns: each header has a hover menu and can be dragged, and a columns button opens the columns menu and shows how many are hidden. The button sits in the panel header next to the title, or at the right end of the table’s header row when the table has no panel header. The state lives in
displayed_columns, an ordered list of column keys (a column’sid, which defaults to itsvalue). Omit it and the choice lasts for the page; bind it to@expr/local_state("displayed_columns": { "@expr/local_state": { "key": "services_columns", "schema": { "type": "array", "items": { "type": "string" } } } }) to keep it on the user’s machine; set a literal list ("displayed_columns": ["ServiceName", "Latency"]) to pin the columns with no controls. Keys naming no column are ignored, and an empty selection shows every column. - Cell descriptions. A value column may carry a
description— a second per-row line rendered beneath the value in a smaller, muted style. It takes the same SQL shape asvalue: a column name reads another row column ("description": "Owner"), any other expression is computed in the query ("description": "concat(Owner, ' • ', Region)"). Set on any column, it bumpsrow_height(see below) to"1.5"under the default"auto", so cells have room for both lines. - Cell background color. A value column may carry a
background_color— a semantic tone (default,muted,info,success,warning,error,inverted) that tints the whole cell via the tone’scolorPalette.subtlefill. Resolved per row with the row’s fields in scope, so an@expr/caseyields conditional row tinting ("background_color": { "@expr/case": { "severity": { "high": "error", "medium": "warning", "low": "success", "default": "default" } } }); a literal tone applies the same fill to every row. Tones without a palette (default,muted) drop through as no tint. - Row background color. The block itself takes a
background_color: a SQL expression whose per-row value is a tone name, spliced intofromas an aliased select item so the database (or the in-memory planner) computes the tone alongside the row data. The tone applies to every cell in the row unless a column’s ownbackground_coloroverrides it. A row whose value does not name a tone renders no tint and warns to the console once per unrecognised value. Example:"background_color": "multiIf(Outcome = 'Ok', 'success', Outcome = 'Error', 'error', 'default')". - Row height.
row_heightscales body and header rows by a line-height ratio:"1"for the single-line default,"1.5"when cells need room for adescriptionbeneath the value."auto"(the default) picks"1.5"when any column has adescription, else"1". Set explicitly to force a ratio regardless of what the columns declare —"1.5"widens the rows without a description;"1"drops any authoreddescriptionfrom the cell, since the single-line layout has no room to render it. - Drill-in. Set
clickto a triggerable — typically a@block/drawer— to make rows open a detail view for the row they belong to. - Publishing rows. A
clickdrawer already sees the clicked row’s columns as individual context values (so{{ServiceName}}in a template and{ServiceName:String}in a query just resolve). Setasto also publish rows as whole tables the subtree can bind by name — the left side is the output, the right side the context key it lands under:"as": { "selected": "row" }binds the clicked row (a single-row table) underrow,"visible"the current page,"all"the full result set. Read one field with@expr/get("row.Duration"reads that cell), or point a table-shaped block straight at it —@block/kv from: "selected"renders the clicked row as a detail record with no re-query ("direction": "row"lays its pairs out as a horizontal header strip with vertical separators instead of the default ruled column). A bare string publishes the selected row ("as": "row"); a list publishes each output under its own name ("as": ["visible", "selected"]). - Row links. Set
linkto a target to make the whole row a real navigable link instead — copy address, open in a new tab, and keyboard focus all work natively. It takes the same three arms as@block/link(frame,frame_instance,to), carrying a per-rowlabelinside the arm instead of avariant, and each arm also accepts a bare string shorthand for its id or URL. The target andlabelare evaluated per row with that row’s fields in scope (so a conditional target is just an@expr/case; the label is the anchor’s accessible name and is not shown).linkandclickare mutually exclusive. A cell can still hold its own@block/link(via a block column) and stays clickable above the row link. - Overflow. Every table either paginates or scrolls. The default is pagination; set
overflowto{ "scroll": {} }to keep all rows in a scroll area instead, or to{ "pagination": { "page_size": 25 } }(thepage_sizeis bindable to a control) to size the pages. Prefer a page size from 1, 5, 10, 25, 50, 100 — familiar steps keep tables consistent across pages. - Content height.
content_heightis"fixed"by default — the body reserves a full page’s height so a short last page keeps the same footprint (no jump when paging). Set"auto"to size the body to its content instead. The panel’s ownheightsizes the card around it. In a notebook cell the body always sizes to its content: the result sits in the document’s flow, where reserved blank space is a hole rather than a stable slot. - Deferred columns. When some columns are much more expensive than the rows themselves (an aggregate over a fat fact table, a join from another signal), split them out with
defer: keepfromas the minimal query that defines the rows — identity, sort key, count — and declare each expensive column set as a nameddeferentry with its own Table-valuedfromand anonjoin key (a bare string joins same-named columns; a record maps{ "table column": "defer column" }). The rows render immediately with shimmer placeholders in the deferred cells; each entry loads in the background and left-joins in when it lands. A defer query sees the table’svisiblerows in scope, so it may bound itself to the displayed page ("where": "Sid IN (SELECT Sid FROM visible)") — or ignore it and load the whole window once, so page flips are free. Deferred columns aren’t sortable and take no cell filter, since sorting and table-local predicates compose into thefromquery, which doesn’t have them. - Spark cells. Wrapping a defer entry’s timeseries in
@expr/nestgives every row its own mini chart: each row’s sub-table feeds a compact block-column plot —{ "bar_y": { "compact": true, "chrome": false, "content_width": 140, "from": "series", "y": "Events" } }. The plot sizes its own box viacontent_width(px), needs no height (the compact 24px minimum fits the row), and omitsx— a bucketed series defaults to the aligned time column, whose axis pins to the ambient window so every row’s spark shares the same x-domain.
A rows-first table whose usage columns fill in from a second query:
{ "@block/table": { "from": { "@expr/query": { "from": "logs", "select": [{ "as": { "Sid": "SessionId", "LastActive": "maxOrNull(Timestamp)" } }], "group_by": "Sid", "order_by": { "by": { "LastActive": "desc" } } } }, "defer": { "usage": { "from": { "@expr/query": { "from": "metrics_sum", "select": ["SessionId", "Cost", "Tokens"], "group_by": "SessionId" } }, "on": { "Sid": "SessionId" } } }, "columns": [ { "title": "Last active", "value": "LastActive", "format": "relative" }, { "title": "Cost", "value": "Cost", "format": { "currency": "USD" } } ] }}A query-shaped table with formatted columns and row drill-in:
{ "@block/table": { "from": { "@expr/query": "SELECT SpanName, Duration, StatusCode FROM traces" }, "columns": [ { "title": "Span", "value": "SpanName" }, { "title": "Duration", "value": "Duration", "format": "duration" } ], "click": { "@block/drawer": { "title": "Span detail", "@block/text": "…" } } }}Make each row a link to that row’s frame instead of a drawer:
{ "@block/table": { "from": { "@expr/query": "SELECT ServiceName, region FROM services" }, "link": { "frame": { "id": "services/{ServiceName}", "params": { "ServiceName": { "@expr/get_context": "ServiceName" } }, "label": { "@expr/handlebars": "View {{ServiceName}}" } } } }}Switch from paging to a scroll area:
{ "@block/table": { "from": { "@expr/query": "SELECT service, region FROM services" }, "overflow": { "scroll": {} } }}@block/arc
Part-of-whole as wedges: one slice per group, sized by summed value. Reach for it to show composition — traffic by service, cost by team. The shape is an external variant, the way @block/plot tags its marks: pie (a full circle), donut (a centered hole, inner_radius defaulting to 0.6), or sunburst (an array group, nesting one ring per level for a hierarchy).
{ "@block/arc": { "donut": { "from": { "@expr/query": "SELECT service, count FROM requests" }, "group": "service", "value": "count", "inner_radius": 0.5 } }}@block/treemap
Part-of-whole as nested rectangles, sized by value. Prefer it over an arc when the breakdown is hierarchical or has many categories — a treemap packs far more groups legibly than a pie.
{ "@block/treemap": { "from": { "@expr/query": "SELECT region, service, count FROM requests" }, "group": ["region", "service"], "value": "count" }}@block/waffle
An infrastructure waffle map: one colored cell per entity (pod, host, container, CPU core), optionally partitioned into labeled sections by group — the host-map chart shape, in hex (default) or square cells. Groups form squarish multi-row clusters, contiguous by default (cell_gap: 0), on an outline-only background lattice; identity lives in the hover tooltip rather than inline labels.
from must produce one row per entity (duplicates throw). color drives the cell fill — numeric through a ramp, categorical through swatches, or by group when omitted — and the cell’s outline derives from it. fill takes a 0–1 share that fills the cell in its own color, from the bottom by default; { "expression": "mem_share", "origin": "center" } grows it from the centre instead, as an area-true shape. cell_gap spaces the cells. A metric with absolute meaning (CPU share, disk fullness) calls for a threshold scale with authored breakpoints — threshold bins get a labeled legend. Cells sort hottest-first within each section, and limit (default 500) caps the map proportionally across groups, with a “Showing N of M” footer.
Interactivity: hovering shows the entity’s values; group headers (and categorical legend entries) toggle filter pills; link makes every cell a real anchor to its entity page (the target resolves against the cell’s row, like a table row link), while click opens a triggerable peek with the row’s fields in scope — the two are mutually exclusive.
{ "@block/waffle": { "from": { "@expr/query": "SELECT K8sPodName, K8sNamespaceName, avg(Value) AS CpuCores FROM metrics_gauge WHERE MetricName = 'k8s.pod.cpu.usage' GROUP BY K8sPodName, K8sNamespaceName" }, "entity": "K8sPodName", "group": "K8sNamespaceName", "color": "CpuCores", "scales": { "color": { "type": "threshold", "domain": [0.5, 1, 2], "scheme": "oranges" } }, "link": { "frame": { "id": "@opentelemetry/k8s/{K8sNamespaceName}/{K8sPodName}", "params": { "K8sNamespaceName": { "@expr/get": "K8sNamespaceName" }, "K8sPodName": { "@expr/get": "K8sPodName" } } } } }}@block/graph
Relationships between entities. Map source / target columns to edges and the unique values become nodes — a service dependency map, a call graph. Use layout to pick the algorithm and direction. A weight column scales each edge’s stroke width (√-normalised, so hot paths read without flattening the tail). To make vertices interactive, add a nodes table — { from, id }, one row per vertex — and give it a click triggerable: clicking a vertex that has a metadata row activates it — a drawer, say — with that row’s fields in context, the same contract as a table row click. click lives inside nodes because that row is what the activation carries; a vertex the nodes table doesn’t describe has nothing to open. nodes.icon names a column holding a per-vertex icon — a Lucide name, or a telemetry.sdk.language value for a language mark; a vertex whose row resolves to an empty or unknown name keeps the default glyph, so a partially-attributed column still renders.
{ "@block/graph": { "from": { "@expr/query": "SELECT src_service, dst_service, calls FROM dependencies" }, "source": "src_service", "target": "dst_service", "weight": "calls", "nodes": { "from": { "@expr/query": "SELECT ServiceName, Icon FROM services" }, "id": "ServiceName", "icon": "Icon", "click": { "@block/drawer": { "title": { "@expr/handlebars": "{{ServiceName}}" }, "@block/text": "…" } } }, "layout": { "algorithm": "layered", "direction": "DOWN" } }}@block/waterfall
The trace view: span events laid out over time and nested by parent. Map start/end timestamps, a span id, and a label; add parent_id to build the call tree. A click triggerable makes every span activatable — clicking a span’s label or bar opens it with that span’s fields in context, the same contract as a table row click; as additionally publishes the clicked span as a single-row table (selected) that a @block/kv can render whole.
Two more channels say what the label alone can’t. description is a second line under it — the service, the peer, the route the span acted on. Authoring it grows every row to two lines, so the timeline keeps a uniform rhythm; a span whose column reads empty just leaves the line blank. Both lines truncate to the label column’s width — hovering the span, on either its label or its bar, reads them in full alongside its timings. color drives the bar’s fill through scales.color, exactly like the other chart families: categorical over the distinct values (colour by service), a ramp when the column is numeric, and { "palette": "outcome" } when the column carries span status. It takes share too, so a waterfall’s services land on the same colours as the chart beside it.
{ "@block/waterfall": { "from": { "@expr/query": { "from": "traces", "select": { "as": { "start": "Timestamp", "end": "Timestamp + Duration", "spanId": "SpanId", "spanName": "SpanName", "serviceName": "ServiceName", "parentSpanId": "ParentSpanId" } } } }, "x1": "start", "x2": "end", "y": "spanId", "label": "spanName", "description": "serviceName", "color": "serviceName", "scales": { "color": { "share": "service" } }, "parent_id": "parentSpanId", "as": "selected", "click": { "@block/drawer": { "title": { "@expr/handlebars": "{{spanName}}" }, "@block/kv": { "from": "selected", "columns": [{ "value": "spanName" }] } } } }}@block/timeline
The change timeline: detection events — alert transitions, change points, deploys, error bursts — as a tagged union at the block root. Author { '@block/timeline': { rail: {…} } } for a wide axis-aligned strip on a shared time axis, or { '@block/timeline': { tall: {…} } } for a latest-first feed beside a center rail (with an optional per-row append block, and compact / row_height / overflow.cap controls). Both variants share the same channels: x1 (moment), label (display), severity (critical/warning/ok/info/neutral), detail, before/after (change pair for the hover card), window and x2 (rows whose window reading is true are windows from x1 to x2, such as an alert from fire to resolve, tinted in the event’s severity; a null x2 on a window row runs to the domain edge), defer (columns loaded non-blockingly and left-joined onto the events, same shape as @block/table’s defer), click, and as. Colors ride the shared annotation_severity chart palette, so a timeline event and a plot annotation of the same severity read on the same tone.
The rail adds two props derived from the events. labels are the captions placed above and below the strip, one per row of a labels table. Without a pipeline the labels table is the events table: "labels": { "text": "caption" } gives every event with a non-empty caption its own caption. With a pipeline, the operators run over the events table, typically a from-less @expr/query that groups, sorts, and limits it. The rail places labels in table order and drops a label that fits no tier near its moment, so sorting by severity keeps the critical captions. A pipelined label covers the events whose on columns equal its own (same shape as defer.on), narrowed to its [x1, x2] span when both columns are set. count suffixes the caption with ×N. side names a column holding above or below to fix a label to that side of the rail; other labels take either side. Labels go above the rail, which sits at the bottom of the strip; a label fixed below splits the strip between both sides. An omitted x places the label at its earliest covered event, and an omitted severity reads the highest covered severity.
chips is a row of toggle chips above the strip, one per row of a chips table built the same way. Each chip shows its label, count, and severity, and covers the events matching its on columns (the label column when omitted). Clicking a chip hides its events and their windows, and the captions whose events are all hidden, on that rail only. Hovering a window picks its event when no marker is near, and the tooltip shows the window’s start, end, and duration.
Clicking a caption activates click with the label’s row bound, so a drawer opened from a grouped caption can scope a feed to the group’s key.
{ "@block/timeline": { "rail": { "from": { "@expr/query": { "from": "changes", "select": [ { "as": { "at": "Timestamp", "title": "concat(ChangedValue, ' shifted ', ChangeDirection)", "valueBefore": "ChangeValueBefore", "valueAfter": "ChangeValueAfter" } } ] } }, "x1": "at", "label": "title", "before": "valueBefore", "after": "valueAfter", "labels": { "pipeline": [ { "@expr/query": { "select": [{ "as": { "title": "title", "events": "count()" } }], "group_by": "title" } } ], "text": "title", "count": "events", "on": "title" } } }}@block/kv
A detail record: one row rendered as label · value pairs — the natural body for a drawer opened from a table or waterfall click. Point from at a one-row table and list columns (the same column shapes as @block/table, except that a string value names a field of the record rather than being SQL: a field, a format, a template, or a block). To group the pairs under headings, use sections instead of columns — each section takes a title and its own column list. The default direction aligns values right and separates adjacent pairs with emphasized dotted rules. A block value gets up to two thirds of the row: a content-sized block such as a badge stays at the right edge, and a width-filling block such as @block/progress fills that space, so a column of progress rows starts and ends on the same lines. "direction": "row" lays the pairs out as a horizontal header strip with vertical separators. The strip wraps between complete pairs when needed; pair text stays on one line, and long values truncate. "variant": "outline" adds a square border and internal padding around the complete record.
{ "@block/kv": { "from": "selected", "columns": [ { "title": "Span", "value": "SpanName" }, { "title": "Duration", "value": "Duration", "format": "duration" } ] }}@block/session
Renders an agent conversation — user turns, assistant replies, tool use — as a threaded timeline, from a table with one row per content block. Point from at the rows; the block reads well-known columns (SessionId, Timestamp, SessionRole, Type, Content, token counts, and so on), each overridable with a prop of the same name when your columns are named differently. Copy the working example from @frames/@claude_code/sessions/ — the shipped Claude Code session detail page — rather than starting from scratch.
@block/workflows
Lists the workflows configured in scope, with each one’s run history — a @block/table whose rows come from the workspace’s workflow definitions and whose run columns fill in from @noemata/views/workflow_runs (last run, outcome, run and failure counts, duration). scope selects the workflows by the frame their defining file borrows its view from: frame (the default) is the frame the block renders in, subtree adds every frame beneath it on the route tree, and all lists the whole workspace, adding a Frame column. The table props (columns, click, link, compact, overflow, sort, empty, as, content_height) pass through; a columns override or a click drawer can name any row field — Ref, Name, Title, Path, FrameId, Enabled, Schedule, Kind, Stateful, Definition (the resolved definition as a json cell) — and any run field — RunCount, FailureCount, LastRunAt, LastOutcome, LastError, LastDurationMs, DurationP95. The run columns query the @noemata pack’s view inside their own @block/frame, so the host frame does not import it; the host’s time range bounds the history, its filters do not apply.
{ "@block/workflows": { "title": "Detections", "scope": "subtree" } }A workspace-wide list whose rows open the definition:
{ "@block/workflows": { "scope": "all", "click": { "@block/drawer": { "title": { "@expr/get_context": "Title" }, "@block/kv": { "from": "row", "columns": [ { "title": "Ref", "value": "Ref" }, { "title": "Schedule", "value": "Schedule" }, { "title": "Last error", "value": "LastError" } ] } } }, "as": { "selected": "row" } }}@block/progress
A bar for a bounded ratio — disk usage, error budget burndown, a step counter. Give it a fraction between 0 and 1.
{ "@block/progress": { "value": 0.72 } }prepend and append render text on the left and right of the bar, vertically aligned with it. Use them for a metric name and its current value, or for start and end labels around the bar.
{ "@block/progress": { "value": 0.72, "prepend": "Disk", "append": "72%" } }