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

Browser

Prism runs end-to-end in the browser via a single prism.wasm artifact. The full six-stage pipeline (spec → validate → plan → compile → encode → render) executes client-side; no server round trip is required to produce SVG from a spec.

What ships

prism static-bundle --wasm <out-dir> writes a self-contained bundle:

<out-dir>/
├── prism.wasm           # cmd/prismwasm binary (TinyGo, GOARCH=wasm); ~6.9 MiB raw
├── prism.wasm.gz        # gzipped binary (~2.2 MiB) — what the loader fetches
├── wasm_exec.js         # TinyGo's WASM loader (paired with the TinyGo binary)
├── prism.mjs            # thin bootstrapper + SceneHandle facade
├── prism-element.mjs    # <prism-chart> / <prism-dataset> / <prism-coordinator>
├── prism-resolver.mjs   # page-level dataset registry
├── prism-selection.mjs  # selection state + DOM event wiring
└── index.html           # minimal loader example

The build toolchain

Prism’s WASM module is built by TinyGo — the single, canonical browser artifact:

BuildCommandRawGzippedLoader
TinyGomake build-wasm-tinygo~6.9 MiB (7,239,767 B)~2.2 MiB (2,232,605 B)TinyGo’s wasm_exec.js

TinyGo links a lean runtime and GC, producing a module roughly half the size the standard Go toolchain would emit. make build-wasm-tinygo writes bin/prism.wasm + bin/wasm_exec.js, which are paired — the loader comes from $(tinygo env TINYGOROOT)/targets/wasm_exec.js and is not interchangeable with the Go toolchain’s loader. TinyGo 0.41.1+ is required (brew tap tinygo-org/tools && brew install tinygo); the build uses -stack-size=8MB so the JSON-Schema shape validator’s recursion does not trap. The former standard-Go js/wasm build path was retired, so make build requires no wasm toolchain.

Wire size and the raw/gzip gap

The WASM module is larger uncompressed than on the wire; the size you actually pay depends entirely on compression:

  • The prism static-bundle --wasm bundle ships both prism.wasm and prism.wasm.gz. The standalone loader fetches the .gz and decompresses it in-page via DecompressionStream("gzip"), so the gzipped payload is what crosses the wire even on a dumb static host that does no content-negotiation. The raw prism.wasm stays as a fallback (WebAssembly.instantiateStreaming) for environments without DecompressionStream or where the .gz is absent.
  • If you wire up your own loader, either fetch prism.wasm.gz and decompress as above, or serve prism.wasm with Content-Encoding: gzip/br so the browser decompresses transparently. Do not serve the raw prism.wasm uncompressed. (nginx: add application/wasm to gzip_types; most CDNs negotiate automatically but some skip files over a size cap.)

CI size gate

The TinyGo artifact is guarded by internal/gates/wasm_tinygo_size_test.go, which checks both the gzipped size (PRISM_WASM_TINYGO_MAX_BYTES, 4 MiB) and the raw size (PRISM_WASM_TINYGO_RAW_MAX_BYTES, 12 MiB) so the uncompressed artifact cannot balloon unnoticed behind the gzipped check. CI pins TinyGo 0.41.1 and runs this gate as a hard requirement — it builds a fresh TinyGo module into a temp directory so the measurement is independent of whatever last populated bin/. Locally the gate skips cleanly when tinygo is not on PATH, so make test stays green without the toolchain installed.

Load modes

Three ways to put a chart on a page, each compatible with the others on the same page:

Server-rendered scene (zero client compile)

The host emits Scene IR JSON server-side (via prism scene) and references it from a <prism-chart src=…>:

<prism-chart src="/scenes/brand_score.json"></prism-chart>

Fastest path. The browser fetches the JSON and renders it via WASM. No spec parsing or transform execution in the browser.

Client spec compile (WASM default)

The host passes the spec inline or as a URL on the spec attribute:

<prism-chart spec='{"$schema":"urn:prism:schema:v1:spec",...}'></prism-chart>
<prism-chart spec="/specs/brand_score.prism.json"></prism-chart>

The spec carries its own rows: inline data: {values: [...]} / datasets.*.values, or a datasets attribute on the element. For lazy or large data, register a JS DataResolver via prism.setDataResolver(...) and reference it with data: {ref}. Prism never fetches or decodes a .pulse file in the browser — the host materializes the rows and hands them to Prism. WASM then runs the full pipeline and mounts the resulting SVG.

A mark: {type: "custom", renderer: "..."} reference resolves the same way against a registry — but a per-mark render function instead of a per-ref data source — via prism.registerCustomMark(name, fn). This is the only way a custom mark works against the shared, prebuilt prism.wasm binary (there’s no way to call a Go function to register into its compiled-in registry from JS). See the Custom marks cookbook for the callback shape and — importantly — the security contract around escaping and script execution.

Render backends: SVG vs HTML

The WASM module exposes both host render backends. prism.render(sceneJSON, themeName?) renders through the canonical SVG backend (render/svg) and returns an <svg>...</svg> string — this is what <prism-chart> calls by default. prism.renderHTML(sceneJSON, themeName?) renders the same SceneDoc through the HTML backend (render/html) instead, returning a complete standalone HTML document string:

const sceneJSON  = globalThis.prism.execute(specJSON, datasetsJSON);
const svgString  = globalThis.prism.render(sceneJSON);
const htmlString = globalThis.prism.renderHTML(sceneJSON);

Both accept the same arguments and return the same {ok:false, error} envelope shape on failure. Most marks render identically either way (the HTML backend just wraps the same SVG emitters in a document shell), but two mark shapes have no SVG geometry of their own and are reachable only through prism.renderHTML:

  • The table mark, which renders as a semantic <table> (see marks.md).
  • A mark: {type: "custom", ...} reference whose registered renderer implements HTMLCustomRenderer (its output lands verbatim in the document, including any <script> tag) rather than SVGCustomRenderer — see the Custom marks cookbook. Before this bridge existed, HTMLCustomRenderer marks could only be rendered server-side (prism plot --format html) or via a Twirp round trip; they are now live-renderable in the browser like any other mark.

<prism-table>

<prism-table> is the live counterpart to the server/CLI-only table path (prism plot --format html): it resolves a scene the same two ways <prism-chart> does — a src attribute fetched via PrismResolver.fetchJSON, or a spec attribute (inline JSON or a URL) compiled via executeSpec — then renders it through prism.renderHTML instead of prism.render, since the table mark has no SVG geometry of its own:

<prism-table src="/scenes/accounts_table.json"></prism-table>
<prism-table spec='{"$schema":"urn:prism:schema:v1:spec",...}'></prism-table>

Its observedAttributes are src, spec, and theme (row sort/pagination/selection are all handled client-side by prism-table.mjs’s installTableHandlers, so there’s no attribute for them). On every render it mounts the returned HTML document’s content into its shadow root and calls installTableHandlers(this. shadowRoot), so column-header sort, Prev/Next pagination, and row-click selection (a prism:select CustomEvent, mirroring <prism-chart>’s selection events) work immediately with no further wiring. See prism-table.mjs for the full data-prism-* attribute contract render/html’s table renderer emits and this element’s sort/pagination logic reads.

Server compile (opt-in)

Hosts that prefer to offload the compile stage to a trusted backend add a compile-server attribute:

<prism-chart spec="/specs/brand_score.prism.json"
             compile-server="/prism/scene"></prism-chart>

The browser POSTs the spec + dataset map to the server (prism serve Twirp endpoint from P14) and gets back the resolved Scene IR. WASM still does the final SVG render; the network round-trip only covers compile.

Auto light/dark in the browser

A spec’s theme.dark_variant (see Dark variant pairing) needs no browser-specific wiring — no new <prism-chart> attribute, no prism.mjs export, no compile-server option. Set it directly in the spec JSON exactly as you would for the CLI:

<prism-chart spec='{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [{"category": "alpha", "value": 12}]},
  "mark": "bar",
  "encoding": {
    "x": {"field": "category", "type": "nominal"},
    "y": {"field": "value", "type": "quantitative"}
  },
  "theme": {"dark_variant": "dark"}
}'></prism-chart>

Every code path the element can take — client-side WASM compile, compile-server offload, or a pre-rendered src="/scenes/…json" scene fetched and mounted with zero compile — reaches the same encode.Encode call under the hood, so all three carry the doubled <style> block described in CSS variables emitted without special-casing.

Because the mechanism is a plain @media (prefers-color-scheme: dark) rule embedded in the SVG/HTML payload itself (not something prism.mjs or the shadow DOM applies separately), a live <prism-chart> mounted on a real page repaints immediately when the visitor flips their OS or browser color-scheme setting — the browser re-evaluates the media query on its own. No SceneHandle.update() call, no animator tween, no re-render round trip: this is a lighter-weight mechanism than the animation system below, which exists for a different problem (data changing between successive scenes, not the viewer’s color-scheme preference).

Theme names must resolve client-side too. dark_variant (and theme.name/the render-time themeName argument to prism.render/prism.renderHTML) are looked up by name against the themes compiled into the running prism.wasm binary. The WASM entry exposes no theme.LoadFile/LoadBytes-equivalent export — there is no way to register a custom theme JSON document from JS at runtime — so a spec embedded in a page can only pair with one of the five built-ins (light, dark, print, high_contrast, colorblind) unless the host ships a custom-built prism.wasm with additional theme.Register calls compiled in.

Compile-only mode

Callers (particularly programmatic ones constructing specs from logic) can ask Prism “what would this render produce?” without paying the cost of rasterising. The WASM module exposes a compile export that returns the structured CompiledPlan — the same intermediate representation the render stage consumes, just exposed publicly:

const planJSON = globalThis.prism.compile(specJSON, datasetsJSON, optsJSON);
const plan = JSON.parse(planJSON);
// plan.marks         — flattened mark summary (per layer)
// plan.scales        — resolved scales (channel, type, domain, range)
// plan.data          — dataset bindings (named + resolved)
// plan.layout        — width/height + grid rows/cols
// plan.diagnostics   — PRISM_WARN_* warnings
// plan.scene         — full Scene IR (same as `prism.execute` output)

Cost is dominated by aggregation over the materialized rows (the executor); the flattened plan view itself is light. For specs whose data fits in memory, compile-only typically runs 10–50× faster than a full prism.execute + prism.render pair, since the encode + SVG-emit stages are skipped.

The Go-native API exposes the same surface:

plan, err := prism.Compile(ctx, spec, prism.CompileOptions{})

Use cases:

  • Programmatic introspection — verify that the color encoding bound the field you expected.
  • Plan diffing — compare two CompiledPlans to know what changed between spec edits without rendering both.
  • Pre-render previews — show the user “3 marks across 2 facets” before committing to a render.

Fetch-backed assets

Prism never fetches or decodes data rows in the browser — the host supplies them inline (data.values / datasets.*.values) or through a JS DataResolver registered with prism.setDataResolver(...). The only assets Prism itself fetches are geodata tiers (geoshape / geopoint marks), pulled from ${origin}/static/prism/geodata/ (override via prism.geo.setBundleURL(url)), and any URL-referenced Scene JSON the page loads directly. Those GETs go through a fetch adapter that dedupes by URL and buffers the body for the page lifetime.

A failed asset fetch surfaces as PRISM_WASM_001 (CORS, network, or non-2xx). It arrives in the JS bridge as a standard {ok:false, error} envelope; prism.mjs rethrows it as an Error with prismCode + prismFixups attached.

What’s still in JS

The four .mjs files together total ~10 KiB. They handle the DOM-side work that WASM can’t reach across the bridge cheaply:

FileResponsibility
prism.mjsLoad WASM, marshal JSON, mount SVG, expose SceneHandle
prism-element.mjs<prism-chart> / <prism-dataset> / <prism-coordinator> custom elements
prism-resolver.mjsPage-level dataset registry; dedupes fetches across charts
prism-selection.mjsPointer-event hit testing against data-prism-* attrs; URL-hash persistence

JS-side scale resolution, axis layout, tick generation, palette resolution, and number/time format are all gone — they used to exist as a reimplementation of the Go pipeline in prism.mjs and were deleted in P17 once the WASM path landed. There is one implementation of every Prism stage now, written in Go.

Animation

The spec animation block produces hints in the emitted Scene IR (scene.animation + mark.key). The SVG renderer ignores these fields entirely; only the web component and the WASM runtime tween between successive scenes.

How the animator works

When <prism-chart>’s spec or src attribute changes and the new scene declares an animation block, the element holds the previous SceneHandle alive and calls handle.update(newSceneDoc) instead of the default clear-and-replace path.

SceneHandle.update defers to PrismAnimator (vendored in static/vendor/prism/prism-animator.mjs):

  1. The new scene is rendered through the WASM module into a detached SVG; its visibility is set to hidden so the user keeps seeing the live (previous) SVG.
  2. PrismAnimator indexes both SVGs by data-prism-mark-key and partitions marks into enter / update / exit sets.
  3. A requestAnimationFrame loop interpolates numeric attrs (x/y/width/height/cx/cy/r/opacity/…) on the live SVG, writing target values read from the staged SVG. Color attrs (fill, stroke) interpolate through OKLab via oklab.mjs for perceptually smooth transitions.
  4. At t = 1 the previous SVG is removed and the staged SVG becomes visible. The exit set fades to opacity=0 along the way.

Fallbacks

The animator skips and snaps to the new scene when any of the following hold:

  • prefers-reduced-motion: reduce is set by the OS / browser. (Silent — this is the correct UX, not a failure.)
  • The previous scene is structurally incompatible with the new scene (different layer count, different mark family per layer, different axis count). SceneHandle dispatches a prism:warn CustomEvent carrying {code: "PRISM_WARN_ANIM_FALLBACK", message} on its root (the shadow root inside <prism-chart>, otherwise the host element). The event bubbles + composes through the shadow boundary so listeners on the host page receive it without extra plumbing.
  • The animate option is explicitly false (handle.update(doc, { animate: false })). (Silent.)
  • The previous handle does not exist yet (first render). (Silent.)

Listening for the warning:

chart.addEventListener("prism:warn", (e) => {
  if (e.detail.code === "PRISM_WARN_ANIM_FALLBACK") {
    console.warn(`tween skipped: ${e.detail.message}`);
  }
});

Public exports

prism.mjs re-exports the animator surface so embedders can drive a tween on a bare SVG without going through SceneHandle:

import {
  PrismAnimator,
  structurallyCompatible,
  prefersReducedMotion,
} from "/static/vendor/prism/prism.mjs";

The tween engine has zero dependencies beyond oklab.mjs. The WASM binary size is unaffected — animation lives entirely in plain JS.

Where to see it

  • The interactive playground routes every edit through SceneHandle.update(). Pick the Animation › Swap bars example and change any score: the bars tween instead of snapping.
  • The gallery/animation/ entries ship spec + initial-frame SVG; live <prism-chart> cards on the gallery live demo demonstrate the tween when the scene-doc swaps.

Cross-implementation parity

The cross-impl harness (internal/devtools/cross-impl-runner/) asserts byte-equal SVG between the host-native Go renderer and the TinyGo-compiled WASM module. Drift signals a non-deterministic stage or a cross-toolchain float-formatting regression.

Run locally:

make build-wasm-tinygo
PRISM_CROSS_IMPL=1 go test ./internal/devtools/

The runner needs node on PATH; no npm install is required.

TinyGo ↔ host float parity

TinyGo (the sole WASM build) links its own strconv, and every SVG coordinate funnels through the single render.FormatFloat helper (render/precision.go, pinned to 3 decimals). If TinyGo rounded or stringified floats differently from the host Go build, the coordinate goldens would drift — this was flagged as the highest risk of the TinyGo migration.

It does not drift. A dedicated parity harness proves it:

PRISM_CROSS_IMPL_TINYGO=1 go test ./internal/devtools/ -run TinyGo
  • TestTinyGoWasmSVGParity builds a TinyGo wasm module from cmd/prismwasm, renders a float-diverse fixture corpus (bars, curves, trigonometric arcs, bezier ribbons, dense rect/box/violin layouts) under Node with TinyGo’s paired wasm_exec.js, and diffs each SVG byte-for-byte against the committed host-Go go.svg. All fixtures are byte-identical.
  • TestTinyGoFloatFormatParity drives render.FormatFloat over an edge-case corpus (half-way rounding, trailing-zero trimming, negative zero, magnitude extremes, NaN/±Inf) in two builds — host-native and TinyGo wasm — and asserts they agree. The host-side pin lives in render/precision_test.go.

Because parity holds unmodified, no float-emission change was needed: the host Go build and TinyGo already produce identical bytes. The harness is opt-in (mirroring PRISM_CROSS_IMPL) because it needs both node and tinygo on PATH.

Standalone HTML demo

prism static-bundle --wasm ./public/prism writes a working index.html to the output directory. Open it directly with a local static server (the browser refuses file:// for WASM):

prism static-bundle --wasm ./public/prism
cd ./public/prism && python -m http.server 8000
# → open http://localhost:8000/

The demo fetches prism.wasm.gz, decompresses it in-page via DecompressionStream("gzip") (falling back to the raw prism.wasm when unavailable), then renders any <prism-chart> it finds. Replace the bundled index.html with your own page to embed Prism in mdBook, Astro, Hugo, or any other static-site generator — keep the .gz + DecompressionStream pattern (or serve the raw .wasm with Content-Encoding) so you ship ~2.2 MiB, not ~6.9 MiB.