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 (GOOS=js GOARCH=wasm)
├── wasm_exec.js # toolchain-pinned WASM loader (Go runtime)
├── 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
Total wire size at v1: ~12 MiB gzipped (prism.wasm) + ~17 KiB
(wasm_exec.js) + ~10 KiB (the four .mjs files). A static host
that serves the directory with Content-Type: application/wasm
gets streaming instantiation; everything else falls back to the
buffered WebAssembly.instantiate path automatically.
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 browser fetches any referenced .pulse files via the
fetch-backed afero.Fs, runs the full pipeline
in WASM, and mounts the resulting SVG. Inline data
(data: {values: [...]}) skips the fetch path entirely.
Server compile (opt-in)
Hosts that prefer to keep Pulse loading behind 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.
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 data I/O + aggregation (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 Fs
Dataset references resolve through an afero.Fs adapter backed
by browser fetch. The first access to a .pulse URL issues a
GET and buffers the body in memory; subsequent opens reuse the
cached bytes for the page lifetime.
Inline data and short single-shard cohorts work out of the box.
Archive shard references (archive.pulse#shard.pulse) work when
the origin serves the archive in one response — random-access
range reads are a v2 enhancement (see
BACKLOG.md).
Two error codes surface fetch problems:
PRISM_WASM_001— fetch failure (CORS, network, non-2xx).PRISM_WASM_002— origin server rejectsRange:requests (only matters once range support lands).
Both arrive in the JS bridge as standard {ok:false, error}
envelopes; prism.mjs rethrows them as Error instances 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:
| File | Responsibility |
|---|---|
prism.mjs | Load WASM, marshal JSON, mount SVG, expose SceneHandle |
prism-element.mjs | <prism-chart> / <prism-dataset> / <prism-coordinator> custom elements |
prism-resolver.mjs | Page-level dataset registry; dedupes fetches across charts |
prism-selection.mjs | Pointer-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). SVG and PDF
renderers ignore 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):
- The new scene is rendered through the WASM module into a detached
SVG; its
visibilityis set tohiddenso the user keeps seeing the live (previous) SVG. PrismAnimatorindexes both SVGs bydata-prism-mark-keyand partitions marks into enter / update / exit sets.- A
requestAnimationFrameloop 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 viaoklab.mjsfor perceptually smooth transitions. - At
t = 1the previous SVG is removed and the staged SVG becomes visible. The exit set fades toopacity=0along the way.
Fallbacks
The animator skips and snaps to the new scene when any of the following hold:
prefers-reduced-motion: reduceis 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).
SceneHandledispatches aprism:warnCustomEvent 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
animateoption is explicitlyfalse(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 galleryindex.htmldemonstrate 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 Go-compiled WASM module. Drift signals a non-deterministic
stage or a Go toolchain regression — not a JS port mistake.
Run locally:
make build-wasm
PRISM_CROSS_IMPL=1 go test ./internal/devtools/
The runner needs node on PATH; no npm install is required.
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 loads prism.wasm, 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.