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:
| Key | Purpose |
|---|---|
$schema | URN identifier (urn:prism:schema:v1:spec) for editor autocomplete + version pinning. |
data | Where 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). |
transform | Optional array of row-level operations (filter, calculate, aggregate, sort, …). |
mark | What to draw — bar, line, point, pie, sankey, … |
encoding | How 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:
| Field | Default | Notes |
|---|---|---|
duration_ms | 400 | Total tween length, capped at 5000. |
easing | cubic_in_out | One of linear, cubic_*, quad_*, sine_*, expo_* (× in/out/in_out). |
stagger_ms | 0 | Per-mark delay applied in document order. |
enter | fade | fade or none. Marks that appear at scene-swap time. |
exit | fade | fade 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 haskey: true.PRISM_SPEC_024— more than one channel carrieskey: 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:
| Operator | Operands | Meaning |
|---|---|---|
eq ne lt lte gt gte | field + exactly one of value / to_field | Equality / ordered comparison against a literal or another column. |
one_of not_one_of | field + values (non-empty) | Set membership. |
between | field + lo + hi | Inclusive range (lo <= x <= hi). |
is_null not_null | field only | Null-state test. |
and or | non-empty list of predicates | Boolean conjunction / disjunction. |
not | one predicate | Boolean 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, orbetweenevaluate false (the row is excluded unless an enclosingor/notrescues it). Test for null explicitly withis_null/not_null;and/or/notthen 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/maxskip null arguments and return null only when every argument is null.coalescereturns its first non-null argument.concattreats a null operand as the empty string and always yields a (possibly empty) string.casereturns thethenof the first branch whosewhenholds, else theelse. - 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 asPRISM_SPEC_038.
Validation codes:
PRISM_SPEC_037— filter predicate not well-formed (unknown field, type-mismatched comparison,betweenwithlo > hi, emptyvaluesset).PRISM_SPEC_038— calculate expression not well-formed (unknown operand field, literal-zero divisor,asmissing 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:
| Field | Required | Notes |
|---|---|---|
rows | yes | Row-axis groupers. One or more {field: "..."} (category, default) or {field: "...", type: "date", period: "..."} (date bucketing). |
columns | yes | Column-axis groupers. Same shape. |
cell | yes | {aggregate, field, as} — aggregate alias (sum, mean, count, …). |
margins | {rows, columns, grand} — emit total rows with _margin sentinel. | |
normalize | none (default), row, column, total. | |
shape | long (default) returns one row per cell; matrix is reserved. | |
overlays | Post-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:
kind | Column value | Notes |
|---|---|---|
share_of_row | cell / row-margin | cells along a row sum to 1.0 |
share_of_col | cell / column-margin | cells down a column sum to 1.0 |
index_vs_margin | cell / margin × 100 | requires axis (row or column); 100 = on-margin |
zscore_vs_margin | (cell − margin) / sd | requires 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.valuescohort, or the output of an earlier transform. It runs entirely in Prism’s in-memory engine, so you canfilter(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
typeiscategory(default) ordate. A date grouper buckets a temporal field byperiod— one ofyear,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
_margincolumn the encoder leaves on the table — filter them out at the chart level by upstreamfilter-after composition or by avoiding themarginsflag 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:
| Field | Required | Notes |
|---|---|---|
target | yes | Dependent variable (y). |
predictors | yes | Independent variable (x). Exactly one in v1 — the only shape that maps to a 2-D line. |
as | Fitted-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
filteror otherwise derive the cohort first and then fit the trend.PRISM_SPEC_035is 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"}
}
}
| Field | Required | Notes |
|---|---|---|
timeunit | yes | Period: year, quarter, month, week (ISO / Monday start), day. Truncates to the period start. |
field | yes | Temporal field to truncate. |
as | yes | Output 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"}
}
}
| Field | Required | Notes |
|---|---|---|
unpivot | yes | Columns to stack. Each must be numeric — the value column is f64. |
as | no | [key_column, value_column] output names. Defaults to ["key", "value"]. |
data | no | Optional 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 againstPRISM_TABLE_MAX_ROWSbefore anything is materialised; an overflow isPRISM_RESOLVE_007. - Row order is row-major: every measure of input row 0 in
unpivotdeclaration 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
unpivotis refused withPRISM_COMPILE_002rather than yielding zeros or NaN. A key / value name that collides with a carried column is refused the same way — rename throughas.
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:
| series | awareness | consideration | familiarity | trust |
|---|---|---|---|---|
| Northwind | 68 | 47 | 61 | 72 |
| Category average | 54 | 51 | 57 | 58 |
{
"$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:
| series | metric | score |
|---|---|---|
| Northwind | awareness | 68 |
| Northwind | consideration | 47 |
| Northwind | familiarity | 61 |
| Northwind | trust | 72 |
| Category average | awareness | 54 |
| … | … | … |
Four things are worth reading off that spec:
seriessurvives because it is not named inunpivot. 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
unpivotdeclaration 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 theunpivotlist reorders the axis. x_offsetandcolorbind the same field, and both pin the samescale.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"}
}
}
| Field | Required | Notes |
|---|---|---|
stack | yes | Quantitative field to accumulate. |
groupby | no | Fields delimiting one stack — typically the dimension position channel. Omitted stacks the whole table as one group. |
offset | no | zero (default) accumulates from the baseline; normalize rescales each stack onto 0…1; center slides each stack so its own midpoint lands on zero. |
as | no | Output 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. |
data | no | Optional 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.
normalizerescales each stack over its own extent ((v − lo) / (hi − lo)), which reduces tov / totalfor all-positive data. A stack whose values are all zero normalises to zero rather than dividing by zero.centerkeeps 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
sortkey of its own; order the rows with a preceding{"sort": …}transform, or bind theorderchannel, which sorts the rows before the stack accumulates. - The output columns must not collide with existing ones;
PRISM_PLAN_STACK_OUTPUT_COLLISIONnames the offender. Astackfield the upstream table does not carry raisesPRISM_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
xfieldvsx.fieldcaught at parse). Strictness reaches every level: an unrecognised key inside a channel object, or inside that channel’saxis/legendsub-block, fails the decode rather than being dropped. The JSON Schema shape stage agrees —additionalProperties: falseon every$def— soprism validateand a library caller usingspec.Decodealone reject the same document. (That was not always true: a hand-writtenUnmarshalJSONreceives raw bytes and Go does not propagate the outer decoder’sDisallowUnknownFieldsinto it, so each decoder inspec/re-arms strictness itself.internal/gates/spec_strict_decode_test.godrives 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.gorequires every exported, JSON-taggedspec/field to have a typed consumer outsidespec/. - 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. Runprism 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 sceneprintWARN <CODE>: <message>to stderr, so the rendered bytes on stdout stay clean.prism scenealso carries them in the document itself, under the top-levelwarningsarray 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:
| Code | Fires when |
|---|---|
PRISM_WARN_MARK_DEF_INERT | a 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_INERT | a 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_INERT | a 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_BUILT | a 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_SKIPPED | a 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
- Marks, Encoding, Composition.
- Spec field reference — every field with type + description.
- Gallery — 59 worked examples.