Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Spec

A Prism Spec is a JSON document describing one chart. It is the contract between authors (humans / agents) and the Prism pipeline.

Six-stage pipeline

Spec (JSON) → Parse → Validate → Plan → Compile → Encode → Render → Bytes
                                          │
                                          ├─→ Pulse engine (data ops)
                                          └─→ Renderer backend (SVG / Canvas)

Minimum viable spec

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [{"brand_id": "a", "score": 0.62}, {"brand_id": "b", "score": 0.55}]},
  "mark": "bar",
  "encoding": {
    "x": {"field": "brand_id", "type": "nominal"},
    "y": {"field": "score",    "type": "quantitative", "aggregate": "mean"}
  }
}

Five top-level keys are typically present:

KeyPurpose
$schemaURN identifier (urn:prism:schema:v1:spec) for editor autocomplete + version pinning.
dataWhere the rows come from — an inline values array, a runtime ref (resolved by a DataResolver), a named alias, or a geodata feature_collection. The external .pulse source variant was removed (PRISM_SPEC_039).
transformOptional array of row-level operations (filter, calculate, aggregate, sort, …).
markWhat to draw — bar, line, point, pie, sankey, …
encodingHow to bind data fields to visual channels (x/y/color/size/…).

Full top-level field list

$schema       data            datasets        transform
mark          encoding        layer           concat
hconcat       vconcat         facet           repeat
spec          selection       resolve         theme
width         height          padding         background
title         subtitle        description     projection
animation

Exactly one of mark | layer | concat | hconcat | vconcat | facet | repeat must be present. The validator enforces this with PRISM_SPEC_* codes.

Animation

The optional animation block requests a client-side tween whenever the spec swaps. Static SVG output is unaffected — the renderer ignores the block entirely. Only the browser web component (<prism-chart>) and the WASM runtime honour it.

{
  "$schema": "urn:prism:schema:v1:spec",
  "data":    {"name": "sales", "values": [...]},
  "mark":    "bar",
  "encoding": {
    "x": {"field": "region", "type": "nominal", "key": true},
    "y": {"aggregate": "mean", "field": "score", "type": "quantitative"}
  },
  "animation": {"duration_ms": 600, "easing": "cubic_in_out"}
}

Fields:

FieldDefaultNotes
duration_ms400Total tween length, capped at 5000.
easingcubic_in_outOne of linear, cubic_*, quad_*, sine_*, expo_* (× in/out/in_out).
stagger_ms0Per-mark delay applied in document order.
enterfadefade or none. Marks that appear at scene-swap time.
exitfadefade or none. Marks that disappear at scene-swap time.

For the tween to match marks across scene swaps (object constancy), declare a join key on one encoding channel via "key": true. Without a key, validation fires PRISM_SPEC_023.

Animation respects the user’s prefers-reduced-motion setting: the animator snaps directly to the final state when the preference is reduce.

When two scenes are structurally incompatible (different layer count, different mark families, etc.) the animator falls back to an instant replace and emits PRISM_WARN_ANIM_FALLBACK on the prism:warn CustomEvent stream.

Spec rules that govern animation:

  • PRISM_SPEC_022 — unknown easing name.
  • PRISM_SPEC_023 — block declared but no channel has key: true.
  • PRISM_SPEC_024 — more than one channel carries key: true.

Filter transform

filter keeps the rows for which a structured predicate evaluates true. The predicate is a JSON object tree, never an expression string — a raw string value is rejected at decode time. Each predicate node is exactly one of a leaf test or a boolean combinator.

Leaf comparisons — eq, ne, lt, lte, gt, gte — compare a field against a literal (value) or against another column (to_field, a field-vs-field compare):

"transform": [
  {"filter": {"op": "gt", "field": "Horsepower", "value": 100}},
  {"filter": {"op": "eq", "field": "Origin", "value": "USA"}},
  {"filter": {"op": "lt", "field": "sale_price", "to_field": "list_price"}}
]

Set membership — one_of / not_one_of — tests a field against a non-empty candidate set:

{"filter": {"op": "one_of", "field": "Origin", "values": ["USA", "Europe"]}}

Inclusive range — between — keeps rows where lo <= field <= hi:

{"filter": {"op": "between", "field": "year", "lo": 2010, "hi": 2019}}

Null checks — is_null / not_null — take only a field:

{"filter": {"op": "not_null", "field": "quota_mean"}}

Boolean combinators — and / or / not — nest predicates to any depth. A combinator node carries only its branch, never leaf operands:

{"filter": {"and": [
  {"op": "gt", "field": "Horsepower", "value": 100},
  {"or": [
    {"op": "eq", "field": "Origin", "value": "USA"},
    {"not": {"op": "is_null", "field": "Cylinders"}}
  ]}
]}}

Operator reference:

OperatorOperandsMeaning
eq ne lt lte gt gtefield + exactly one of value / to_fieldEquality / ordered comparison against a literal or another column.
one_of not_one_offield + values (non-empty)Set membership.
betweenfield + lo + hiInclusive range (lo <= x <= hi).
is_null not_nullfield onlyNull-state test.
and ornon-empty list of predicatesBoolean conjunction / disjunction.
notone predicateBoolean negation.

The grammar is intentionally minimal — no substring, regex, or date arithmetic. Anything richer is precomputed by the caller before the data reaches Prism.

Calculate transform

calculate appends one derived column, named by as, from a structured expression tree (again, never an expression string). A node is exactly one of:

  • a field reference — {"field": "Horsepower"}
  • a literal — {"literal": 5} (number, string, or bool; a null literal is rejected)
  • an arithmetic op — {"op": "add"|"sub"|"mul"|"div"|"mod", "operands": [...]}
  • a pure function — {"fn": "abs"|"round"|"floor"|"ceil"|"neg"|"coalesce"|"min"|"max", "args": [...]}
  • a string concat — {"concat": [...]}
  • a conditional — {"case": [{"when": <predicate>, "then": <expr>}], "else": <expr>}

add and mul take two or more operands; sub, div, mod take exactly two. abs/round/floor/ceil/neg take one argument; coalesce/min/max take two or more. case requires at least one when → then branch and a mandatory else fallback (if is accepted as a decode-time alias for case).

Arithmetic — Horsepower / Weight:

{"calculate": {"op": "div", "operands": [{"field": "Horsepower"}, {"field": "Weight"}]}, "as": "power_ratio"}

Default a null with coalesce:

{"calculate": {"fn": "coalesce", "args": [{"field": "quota"}, {"literal": 0}]}, "as": "quota_padded"}

Build a label with concat:

{"calculate": {"concat": [{"field": "Origin"}, {"literal": " — "}, {"field": "Name"}]}, "as": "label"}

Bucket with case; each when arm reuses the filter predicate grammar verbatim:

{"calculate": {
  "case": [
    {"when": {"op": "gte", "field": "score", "value": 0.9}, "then": {"literal": "A"}},
    {"when": {"op": "gte", "field": "score", "value": 0.8}, "then": {"literal": "B"}}
  ],
  "else": {"literal": "C"}
}, "as": "grade"}

The output column type is inferred: a numeric expression yields a float column, a string expression a categorical column.

The grammar is intentionally minimal — no log/sqrt/pow/trig, no substring, no date arithmetic. Precompute anything richer upstream.

Null and division semantics

Both filter and calculate use two-valued logic — there is no SQL-style three-valued “unknown”.

  • Filter leaves. A null operand makes a leaf comparison, one_of / not_one_of, or between evaluate false (the row is excluded unless an enclosing or / not rescues it). Test for null explicitly with is_null / not_null; and / or / not then operate on plain booleans.
  • Calculate null propagation. Arithmetic (add/sub/mul/div/mod) and the single-argument numeric functions (abs/round/floor/ceil/neg) propagate nulls: any null operand yields a null result. min / max skip null arguments and return null only when every argument is null. coalesce returns its first non-null argument. concat treats a null operand as the empty string and always yields a (possibly empty) string. case returns the then of the first branch whose when holds, else the else.
  • Division by zero. A runtime zero divisor (div / mod) yields null silently — no error, no warning. A literal-zero divisor (e.g. {"op": "div", "operands": [{"field": "x"}, {"literal": 0}]}) is a spec mistake and is rejected at validate time as PRISM_SPEC_038.

Validation codes:

  • PRISM_SPEC_037 — filter predicate not well-formed (unknown field, type-mismatched comparison, between with lo > hi, empty values set).
  • PRISM_SPEC_038 — calculate expression not well-formed (unknown operand field, literal-zero divisor, as missing or shadowing a source column).

Crosstab transform

The crosstab transform builds a contingency table in Prism’s in-memory engine: it composes the cell aggregation across the row × column grouper grid, recomputes the margin axes, applies the configured normalisation, and returns long-form rows ready for a heatmap encoder.

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"name": "sales"},
  "transform": [{
    "crosstab": {
      "rows":    [{"field": "region"}],
      "columns": [{"field": "quarter"}],
      "cell":    {"aggregate": "mean", "field": "revenue", "as": "mean_revenue"},
      "margins": {"rows": true, "columns": true},
      "normalize": "none"
    }
  }],
  "mark": "heatmap",
  "encoding": {
    "x":     {"field": "quarter", "type": "nominal"},
    "y":     {"field": "region",  "type": "nominal"},
    "color": {"field": "mean_revenue", "type": "quantitative"}
  }
}

Body:

FieldRequiredNotes
rowsyesRow-axis groupers. One or more {field: "..."} (category, default) or {field: "...", type: "date", period: "..."} (date bucketing).
columnsyesColumn-axis groupers. Same shape.
cellyes{aggregate, field, as} — aggregate alias (sum, mean, count, …).
margins{rows, columns, grand} — emit total rows with _margin sentinel.
normalizenone (default), row, column, total.
shapelong (default) returns one row per cell; matrix is reserved.
overlaysPost-result overlay layers; each adds one F64 column aligned to the base cell. See below.

Crosstab overlays

overlays attaches post-result overlay layers to the cell grid. Each overlay adds one F64 column — index-aligned to the base cell — so it can drive a color or opacity channel. v1 supports the cell-scoped kinds that align one-to-one with heatmap cells:

kindColumn valueNotes
share_of_rowcell / row-margincells along a row sum to 1.0
share_of_colcell / column-margincells down a column sum to 1.0
index_vs_margincell / margin × 100requires axis (row or column); 100 = on-margin
zscore_vs_margin(cell − margin) / sdrequires axis; a significance proxy (|z| > 1.96 ≈ p < .05) — bind to opacity for significance shading
"crosstab": {
  "rows":    [{"field": "region"}],
  "columns": [{"field": "quarter"}],
  "cell":    {"aggregate": "sum", "field": "revenue", "as": "revenue"},
  "overlays": [{"kind": "share_of_row", "as": "row_share"}]
}

When any overlay is present the node emits body cells only (overlays decorate body cells), so user margins flags are ignored for the visual output. Group/series-scoped kinds (index_vs_total, share_of_total) land in a follow-up.

Constraints:

  • Crosstab accepts any upstream table — a source-bound dataset, an inline data.values cohort, or the output of an earlier transform. It runs entirely in Prism’s in-memory engine, so you can filter (or otherwise reshape) the rows first and then cross-tabulate the result (see the derived-input example below). It is no longer restricted to the first position on the chain.
  • Grouper type is category (default) or date. A date grouper buckets a temporal field by period — one of year, quarter, month (default), week, day, day_of_week — emitting string bucket-key labels ("2024", "2024-Q1", "2024-03", …). Range / rounded / quantile groupers land in a follow-up.
  • Margin rows carry a _margin column the encoder leaves on the table — filter them out at the chart level by upstream filter-after composition or by avoiding the margins flag for the visual rendering use case.

Cells are validated through PRISM_SPEC_032 (shape rule) and PRISM_SPEC_034 (normalize enum) — both structural checks only. Run prism errors lookup <code> for details + fixups.

Derived-input example

Because crosstab consumes whatever table its upstream stage produces, you can chain it after any other transform. Here a filter narrows the rows to one region before the cross-tabulation runs:

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [
    {"region": "West", "quarter": "Q1", "channel": "online",  "revenue": 120},
    {"region": "West", "quarter": "Q2", "channel": "retail",  "revenue": 80},
    {"region": "East", "quarter": "Q1", "channel": "online",  "revenue": 60}
  ]},
  "transform": [
    {"filter": {"op": "eq", "field": "region", "value": "West"}},
    {"crosstab": {
      "rows":    [{"field": "quarter"}],
      "columns": [{"field": "channel"}],
      "cell":    {"aggregate": "sum", "field": "revenue", "as": "revenue"}
    }}
  ],
  "mark": "heatmap",
  "encoding": {
    "x":     {"field": "channel", "type": "nominal"},
    "y":     {"field": "quarter", "type": "nominal"},
    "color": {"field": "revenue", "type": "quantitative"}
  }
}

Regression transform

The regression transform fits an ordinary-least-squares regression over the materialised source table (pure-Go, in-memory) and emits the two endpoints of the fitted trend line — (min(x), ŷ) and (max(x), ŷ). Because every OLS fitted point is collinear, two endpoints draw the full line; layer a line mark over (predictor, fitted) on top of a point scatter of (predictor, target) for the classic regression overlay.

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"name": "sales"},
  "layer": [
    {"mark": "point", "encoding": {
      "x": {"field": "spend", "type": "quantitative"},
      "y": {"field": "revenue", "type": "quantitative"}
    }},
    {
      "transform": [{"regression": {"target": "revenue", "predictors": ["spend"], "as": "fit"}}],
      "mark": "line",
      "encoding": {
        "x": {"field": "spend", "type": "quantitative"},
        "y": {"field": "fit", "type": "quantitative"}
      }
    }
  ]
}

Body:

FieldRequiredNotes
targetyesDependent variable (y).
predictorsyesIndependent variable (x). Exactly one in v1 — the only shape that maps to a 2-D line.
asFitted-value output column name (default fitted).

Constraints:

  • Regression accepts any upstream table — like crosstab, the OLS fit runs in-memory over whatever rows its input stage yields, so you can filter or otherwise derive the cohort first and then fit the trend. PRISM_SPEC_035 is a structural check only (target present + at least one predictor); there is no first-position requirement.
  • v1 fits unpenalized OLS with a single predictor. Multiple predictors, GLM/Bayesian families, and the per-row residual / leverage attributes land in a follow-up.

TimeUnit transform

The timeunit transform truncates a temporal field to a calendar period and appends the truncated date as a new column — the Vega-Lite timeUnit analogue. The output is a date (the period start), so the derived column stays temporal for axis / scale resolution and sorts chronologically. It runs client-side (pure epoch arithmetic) and, like every Prism transform, composes anywhere in a chain.

{
  "transform": [{"timeunit": "month", "field": "order_date", "as": "order_month"}],
  "mark": "line",
  "encoding": {
    "x": {"field": "order_month", "type": "temporal"},
    "y": {"aggregate": "sum", "field": "revenue", "type": "quantitative"}
  }
}
FieldRequiredNotes
timeunityesPeriod: year, quarter, month, week (ISO / Monday start), day. Truncates to the period start.
fieldyesTemporal field to truncate.
asyesOutput date column name.

day_of_week and other component-extraction units (which return an ordinal, not a date) land in a follow-up.

Unpivot transform

The unpivot transform reshapes wide → long: one input row carrying N measure columns becomes N output rows, each repeating the untouched remaining columns, naming the measure in a key column and carrying its value in a value column. It is the Vega-Lite fold analogue, and the usual way to get several wide metric columns onto one categorical axis (colour or dodge by the key column).

{
  "data": {"values": [
    {"region": "east", "q1": 1, "q2": 2},
    {"region": "west", "q1": 3, "q2": 4}
  ]},
  "transform": [{"unpivot": ["q1", "q2"], "as": ["quarter", "amount"]}],
  "mark": "bar",
  "encoding": {
    "x": {"field": "region", "type": "nominal"},
    "y": {"field": "amount", "type": "quantitative"},
    "color": {"field": "quarter", "type": "nominal"}
  }
}
FieldRequiredNotes
unpivotyesColumns to stack. Each must be numeric — the value column is f64.
asno[key_column, value_column] output names. Defaults to ["key", "value"].
datanoOptional input alias, like every other transform.

Output schema is the input schema minus the unpivoted fields, plus the key column (categorical, carrying the source column name) and the value column (numeric), in that order.

  • Row count is input_rows × len(unpivot). The reshape multiplies rows, so the product is checked against PRISM_TABLE_MAX_ROWS before anything is materialised; an overflow is PRISM_RESOLVE_007.
  • Row order is row-major: every measure of input row 0 in unpivot declaration order, then every measure of input row 1, and so on. A source row’s outputs stay adjacent.
  • Nulls survive. A null measure cell produces an output row whose value is null — the row is not dropped and the value is never coerced to zero. What happens to it next is the encode-time null policy (see Multi-source).
  • Types. A categorical, boolean or date column cannot fill a numeric value column; naming one in unpivot is refused with PRISM_COMPILE_002 rather than yielding zeros or NaN. A key / value name that collides with a carried column is refused the same way — rename through as.

Worked example: wide → long → grouped bar

The usual reason to reshape is that the stored table is wide — one row per series, one column per metric, because in the source schema a metric is a column and not a dimension. A grouped bar wants the opposite: the metric on the category axis, the series dodged inside each slot. unpivot is the step between the two, and the x_offset channel is what dodges the result.

Start from the wide table:

seriesawarenessconsiderationfamiliaritytrust
Northwind68476172
Category average54515758
{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [
    {"series": "Northwind",        "awareness": 68, "consideration": 47, "familiarity": 61, "trust": 72},
    {"series": "Category average", "awareness": 54, "consideration": 51, "familiarity": 57, "trust": 58}
  ]},
  "transform": [
    {"unpivot": ["awareness", "consideration", "familiarity", "trust"],
     "as": ["metric", "score"]}
  ],
  "mark": "bar",
  "encoding": {
    "x": {"field": "metric", "type": "nominal"},
    "y": {"field": "score", "type": "quantitative"},
    "x_offset": {
      "field": "series",
      "type": "nominal",
      "scale": {"domain": ["Northwind", "Category average"]}
    },
    "color": {
      "field": "series",
      "type": "nominal",
      "scale": {"domain": ["Northwind", "Category average"]}
    }
  }
}

The transform turns 2 rows × 4 measures into 8 long rows — series carried through untouched, metric naming the source column, score carrying its value:

seriesmetricscore
Northwindawareness68
Northwindconsideration47
Northwindfamiliarity61
Northwindtrust72
Category averageawareness54
………

Four things are worth reading off that spec:

  • series survives because it is not named in unpivot. Every column the transform does not stack is repeated on each output row, which is what leaves a dimension for the offset channel to dodge on.
  • The x axis order is the unpivot declaration order. Output is row-major, so the first input row emits its measures in the order they are listed, and the band scale assigns slots first-seen from there. Reordering the unpivot list reorders the axis.
  • x_offset and color bind the same field, and both pin the same scale.domain. Pinning is what keeps the legend order and the sub-band order in agreement, and what keeps a template’s pair order from moving when the underlying category set changes between renders — see Encoding › Ordering the sub-bands.
  • The bars dodge, they do not stack. Every bar keeps its own baseline; the offset scale subdivides the metric’s band slot and its padding defaults to zero, so the pair touches and together fills the slot.

The rendered result is the unpivot_grouped_bar gallery fixture.

Pivot transform — parses, does not execute

pivot is the long → wide counterpart of unpivot, and it is the one transform the spec grammar accepts that no backend can run. It decodes, it has a JSON Schema variant, it builds a plan node — and nothing implements that node’s execution.

Rather than let it fail mid-pipeline, validate refuses it up front:

PRISM_SPEC_067: Transform "pivot" at transform[0] is accepted by the spec
grammar but no backend can execute it.

This moved an existing failure earlier; it did not create one. Before the rule, a pivot spec validated cleanly and then died inside execute as PRISM_COMPILE_001, naming an internal node kind (PivotNode) the author never wrote. Every other transform variant executes — filter, calculate, aggregate, bin, window, join, union, unpivot, sample, sort, limit, crosstab, regression, timeunit, stack. That list is not a promise typed into a doc: validate/rules/transform_executable.go states it, and a repo gate drives one minimal spec per variant through the real planner and the real in-memory backend, failing just as loudly when a transform named there stops working as when one omitted there starts.

Use crosstab for the same long → wide shape. It spreads the distinct values of one field across columns and does execute:

{
  "transform": [
    {"crosstab": {
      "rows":    [{"field": "region"}],
      "columns": [{"field": "quarter"}],
      "cell":    {"aggregate": "sum", "field": "revenue"}
    }}
  ]
}

The general answer, when a reshape is outside what the built-in transforms express, is the same one Prism gives everywhere: it consumes already-materialized rows, so do the reshape upstream and hand it the finished table — inline as data.values, through the datasets block, or at runtime via a DataResolver and a data: {"ref": …} binding.

Stack transform

The stack transform accumulates one quantitative field into per-row [start, end] bounds inside each stack, so a bar or area mark can draw each segment as a span instead of anchoring every segment on the axis baseline. It is the Vega-Lite stack transform, and it is what the implicit stacking described in Encoding → Stacking compiles down to.

{
  "transform": [
    {"stack": "users", "groupby": ["stage"], "offset": "zero", "as": ["users_lo", "users_hi"]}
  ],
  "mark": "bar",
  "encoding": {
    "x":  {"field": "stage", "type": "nominal"},
    "y":  {"field": "users_lo", "type": "quantitative"},
    "y2": {"field": "users_hi", "type": "quantitative"},
    "color": {"field": "plan", "type": "nominal"}
  }
}
FieldRequiredNotes
stackyesQuantitative field to accumulate.
groupbynoFields delimiting one stack — typically the dimension position channel. Omitted stacks the whole table as one group.
offsetnozero (default) accumulates from the baseline; normalize rescales each stack onto 0…1; center slides each stack so its own midpoint lands on zero.
asnoOutput column pair [start, end]. Defaults to ["<field>_start", "<field>_end"]. These are column names, not a dataset alias — stack publishes no alias, exactly like bin and calculate.
datanoOptional input alias.

Semantics:

  • Positive and negative values accumulate on independent cursors from zero, so a stack holding both grows up and down from the baseline rather than cancelling.
  • normalize rescales each stack over its own extent ((v − lo) / (hi − lo)), which reduces to v / total for all-positive data. A stack whose values are all zero normalises to zero rather than dividing by zero.
  • center keeps each stack’s thickness and slides the whole pair of bounds so the stack’s own midpoint is zero — the streamgraph silhouette. Output bounds are therefore signed, and every stack is symmetric about the same line no matter how much total it carries. The inside-out segment ordering a centred encoding stack applies (see Encoding › Centred stacks) is not available here — this transform has no grouping channel to derive series from, so it keeps upstream row order like the other offsets.
  • A null or non-numeric cell contributes nothing: it gets a zero-height [cursor, cursor] bound and the cursor does not move.
  • Segment order inside a stack is upstream row order. This transform carries no sort key of its own; order the rows with a preceding {"sort": …} transform, or bind the order channel, which sorts the rows before the stack accumulates.
  • The output columns must not collide with existing ones; PRISM_PLAN_STACK_OUTPUT_COLLISION names the offender. A stack field the upstream table does not carry raises PRISM_PLAN_STACK_FIELD_MISSING.

Like every other transform, stack accepts any upstream table — a leaf, inline values, or the output of an earlier transform — so it composes anywhere in the chain.

Sort direction

The sort transform and the window transform’s sort key both take per-field entries of the form {"field": …, "order": …}. order is "ascending" (the default) or "descending"; "asc" and "desc" are accepted aliases. The same vocabulary applies to the order channel’s sort key.

{"transform": [{"sort": [{"field": "revenue", "order": "descending"}]}]}

A window transform may carry its own sort array — it orders rows within each partition without reordering the table:

{"transform": [
  {"window": [{"op": "rank", "as": "place"}],
   "partitionby": ["region"],
   "sort": [{"field": "revenue", "order": "descending"}]}
]}

Strict by default

  • Unknown fields error (typos like xfield vs x.field caught at parse). Strictness reaches every level: an unrecognised key inside a channel object, or inside that channel’s axis / legend sub-block, fails the decode rather than being dropped. The JSON Schema shape stage agrees — additionalProperties: false on every $def — so prism validate and a library caller using spec.Decode alone reject the same document. (That was not always true: a hand-written UnmarshalJSON receives raw bytes and Go does not propagate the outer decoder’s DisallowUnknownFields into it, so each decoder in spec/ re-arms strictness itself. internal/gates/spec_strict_decode_test.go drives an unknown key through every one of them on each build.)
  • Semantic violations error (agg op on incompatible field type, etc.).
  • A key that decodes and then reaches no consumer is reported as a warning (below) — and, for anything newly added, fails the build: internal/gates/spec_field_consumer_test.go requires every exported, JSON-tagged spec/ field to have a typed consumer outside spec/.
  • 24+ PRISM_SPEC_* rules cover field-existence, channel-for-mark, selection refs, structured filter / calculate predicates, scale type compatibility, animation easing / key constraints, and more. Run prism errors lookup <code> for details on any.

Validate a spec

prism validate my-chart.prism.json
prism validate --json my-chart.prism.json

Warnings — and the fields that do nothing

A spec can be perfectly valid and still ask for something Prism does not do. Those keys used to be discarded in silence; they now report themselves as warnings. A warning never stops the chart: the document still renders, and the warning rides alongside it.

Where they surface:

  • prism plot / prism scene print WARN <CODE>: <message> to stderr, so the rendered bytes on stdout stay clean.
  • prism scene also carries them in the document itself, under the top-level warnings array of the Scene IR (code, message, details) — the same array the browser runtime and the Twirp / MCP facades read.
  • prism errors lookup <CODE> explains any of them, with fixups.

The inert-field family reports a key that decoded, passed validation, and then reached no consumer. Each one names the exact path (layer[1].mark.tooltip, encoding.y.scale.padding_inner) plus the mark or channel it was written on:

CodeFires when
PRISM_WARN_MARK_DEF_INERTa mark_def property is set on a mark that never reads it — pad_angle on a bar, dx on a rect — or on a property no mark reads at all (shape, tooltip, layout)
PRISM_WARN_CHANNEL_INERTa channel binding reaches no encoder: fill, stroke, size, shape (no mark reads them), opacity on anything but heatmap, or a format on a table column that also binds a sub-mark (that column draws geometry, not text)
PRISM_WARN_SCALE_FIELD_INERTa scale property does not apply to the family the channel resolves to — padding_inner on a linear scale, base on anything but log, zero on a log / time / discrete scale
PRISM_WARN_LEGEND_NOT_BUILTa quantitative or temporal color channel builds a gradient legend, but the mark never reads the colour ramp (only heatmap consumes a sequential palette today), so the key describes a scale the marks do not use
PRISM_WARN_FACET_CHILD_SKIPPEDa facet child’s encoding asks for a channel-level aggregate, a stack, or an order — the child encoding is stripped before the plan is built, so none of the three is injected

Silence is intentional for three groups, and none of them warns: a key that is honoured; a key that is rejected outright at validate (orient: "radial" → PRISM_SPEC_046, scale.range on a position channel → PRISM_SPEC_045, bar + stack: "center" → PRISM_SPEC_053 — a rejection is already visible); and a key that is inert by design — the json:"-" internal bindings, and axis.format / legend.format, which a validation rule reads. Universal style properties (fill, stroke, stroke_width, stroke_dash, opacity, …) are never reported either: every mark carries them into its Scene IR style, so no mark type can render them inert. A channel carrying a condition is likewise never reported: the condition pass evaluates it whatever the channel.

The report is produced once per spec, from the top of the composition tree, so a layer or concat child is never warned about twice.

Spec patches (RFC 6902)

Iterative edits to a rendered chart don’t need a full spec re-send. A caller can transmit an RFC 6902 JSON Patch and the library applies it atomically, re-decodes, and re-compiles:

[
  { "op": "replace", "path": "/mark", "value": "area" },
  { "op": "add",     "path": "/encoding/color",
                     "value": { "field": "category", "type": "nominal" } },
  { "op": "test",    "path": "/data/name", "value": "current_window" },
  { "op": "remove",  "path": "/title" }
]

Same protocol in Go and in WASM:

next, err := prism.ApplyPatch(s, patch)
// or, statefully:
scn, _ := prism.NewScene(ctx, s, prism.CompileOptions{})
err := scn.Apply(patch)
const newSpecJSON = prism.applyPatch(specJSON, JSON.stringify(patch));
const patchJSON   = prism.diffSpecs(beforeJSON, afterJSON);

Atomic application. Either every operation in the patch succeeds and the new spec replaces the old, or no state changes. A failing op surfaces as PRISM_SPEC_PATCH_001 with the offending op index in the envelope’s Details.OpIndex.

Test operations. Include a test op to fail-fast on optimistic-concurrency violations — the patch aborts if the current spec value at path differs from the expected value.

Diff helper. prism.DiffSpecs(before, after) (Go) and prism.diffSpecs(beforeJSON, afterJSON) (WASM) produce a patch that transforms one spec into the other. Useful for callers that think in full specs and only want to transmit the delta.

Further reading