Vega-Lite
Vega-Lite is a declarative JSON grammar
of graphics — you describe a chart as data and it compiles (through
Vega) to SVG or canvas. Like the
Mermaid and D2 integrations, @ponchia/ui doesn't
render charts — it themes them from your tokens. Two things ship:
@ponchia/ui/vega—brontoVegaConfig(theme), the on-brand Vega-Liteconfigobject.@ponchia/ui/vega.json— the resolved per-theme config, for any consumer.
This is the idiomatic Vega theme shape — a config, the same kind the
vega-themes package ships. Vega stays
the consumer's renderer; this is config only, and Vega is not a dependency
of bronto (the dev-only render-probe aside).
Why Vega-Lite and not a bronto chart component? A chart needs scales (data → pixels) and data binding — the two things the analytical layer refuses to own. A spec is also something an LLM-from-another-system can emit as data, the same way it emits Mermaid/D2. So bronto themes a real charting grammar instead of shipping a fragile one.
Theme a chart
brontoVegaConfig(theme) returns a config object. Spread it into a spec, or
hand it to vega-embed:
import vegaEmbed from 'vega-embed';
import { brontoVegaConfig } from '@ponchia/ui/vega';
const theme = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
vegaEmbed('#chart', {
data: { values: [
{ quarter: 'Q1', value: 42 },
{ quarter: 'Q2', value: 58 },
{ quarter: 'Q3', value: 50 },
] },
mark: 'bar',
encoding: {
x: { field: 'quarter', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
}, { config: brontoVegaConfig(theme), renderer: 'svg', actions: false });
Pass renderer: 'svg' (not vega-embed's canvas default): an SVG chart is
inspectable, themeable, survives the print/PDF pipeline, and is what the
annotation layer composes onto — a canvas chart prints as a
raster and carries no text alternative.
From a CDN, no bundler
Load Vega + Vega-Lite + vega-embed from pinned /build/*.min.js UMD files,
then pass the config. Pin exact versions and use the /build/ path — a bare
cdn.jsdelivr.net/npm/vega@6 redirect resolves to a module bundle that does
not register the global window.vega, so vega-embed throws and nothing
renders. Keep the three majors aligned: Vega-Lite 6 targets Vega 6 (and
vega-embed 7), so don't mix a Vega-Lite 6 with a Vega 5 runtime:
<script src="https://cdn.jsdelivr.net/npm/vega@6.2.0/build/vega.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@7.1.0/build/vega-embed.min.js"></script>
<script>
// INLINE the config — generate the paste-ready literal with
// `npm run emit:theme vega light` (mirrors the annotations author-time-copy
// pattern). This is the only path that also works from a file:// report.
const brontoLight = {
/* …paste tokens/vega.json → light here… */
};
vegaEmbed('#chart', spec, { config: brontoLight, renderer: 'svg', actions: false });
</script>
file:// portability. A report opened straight from disk (
file://) cannotimportthe@ponchia/ui/vegamodule norfetch('…/vega.json')— the browser blocks both across thenull/file origin (CORS). So for a double-clickable or PDF-bound report, inline the resolved config object (as above) rather than fetching it. Generate the paste-ready, sentinel-tagged literal withnpm run emit:theme vega light(ordark); re-runningnpm run emit:theme:check <file>re-derives every tagged block and fails if a token change has left an inlined copy stale. Over anhttp(s)origin (a dev server, a static host, a bundler), theimport { brontoVegaConfig }form and afetch('https://cdn.jsdelivr.net/npm/@ponchia/ui@VERSION/tokens/vega.json')both work — pin the package version in the URL, since the unversioned latest may predate this target.
Over http(s), skip the inline copy — import the helper as an ES module.
tokens/vega.js (the @ponchia/ui/vega entry) has zero dependencies, so it
loads straight from a CDN as a browser ES module with no bundler and no
import-map. You get brontoVegaConfig(theme) itself (live theme switching), not
a frozen object to keep in sync. Pin the package version; this needs a real
origin (it does not work from file:// — use the inline form above there):
<script type="module">
import { brontoVegaConfig } from 'https://cdn.jsdelivr.net/npm/@ponchia/ui@VERSION/tokens/vega.js';
// vegaEmbed loaded from its UMD bundle above (window.vegaEmbed), or import it as ESM too.
vegaEmbed('#chart', spec, { config: brontoVegaConfig('light'), renderer: 'svg', actions: false });
</script>
For a build step or non-JS host, read @ponchia/ui/vega.json directly
({ light, dark }, each a ready Vega-Lite config).
Why resolved colours, not var(--x)
Vega-Lite compiles a spec to a Vega scene that renders to SVG or canvas —
colours are baked into the output and parsed by d3-color, which understands
real hex/rgb but not var() (nor oklch()). So the config ships resolved
hex per theme, projected from the same token source as
tokens/resolved.json / charts.json.
Re-call brontoVegaConfig() when the theme toggles and re-embed. A page that
switches skin, contrast or the OLED surface at runtime cannot be served by a
per-theme snapshot: build the config from the live page with
@ponchia/ui/renderer — vegaConfig(readTokens()) is the
same mapping these files are generated from.
What the slots paint
The chrome stays quiet and neutral; colour is spent on data. The plot has no frame (a chart already sits on a panel), and a single series takes the first categorical hue rather than the alert accent:
| Slot | Paint | bronto token |
|---|---|---|
background |
Chart canvas | --bg (runtime default: transparent) |
view.stroke |
Plot frame | none (null) |
mark.color |
Default / single-series mark | --chart-1 |
rule.color |
Reference rules, annotations | --line-strong |
axis.domainColor · tickColor |
Axis line · ticks | --line-strong |
axis.gridColor |
Gridlines | --line |
axis.labelColor · titleColor |
Tick labels · axis title | --text-soft · --text |
text.color |
Free text/label marks |
--text |
legend.* · header.* · title.* |
Legend, facet headers, title | --text-soft / --text / --text-dim |
*.font / *Font |
All text | --sans |
rect/arc/area .stroke |
Gap between adjacent fills | --panel |
range.category |
8-series categorical palette | charts.json categorical (blue first) |
range.ordinal · ramp · heatmap |
Single-hue sequential ramp | charts.json sequential |
range.diverging |
− … neutral … + ramp | charts.json diverging |
The palette is the same CVD-safe, pattern-paired set documented in
theming — colour is never the sole channel. When a
series needs the redundant second channel, drive the mark's fill from the
--chart-pattern-* tokens or pair a legend swatch.
Spending the accent
No categorical slot is the accent, so an ordinary chart never reads as an
alert. To emphasise one mark, paint just that mark with the accent and leave
the rest neutral — the same "reserve the accent for the one thing a reader must
not miss" rule the rest of the system follows. Two small helpers hand you the
exact per-theme hexes (baked into the generated files; Vega output does not
live-reskin from --accent):
import { brontoVegaAccent, brontoVegaNeutral } from '@ponchia/ui/vega';
// e.g. a bar chart where only the 'Alert' category is loud:
const spec = {
/* …data… */
mark: 'bar',
encoding: {
x: { field: 'name', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
color: {
condition: { test: "datum.name === 'Alert'", value: brontoVegaAccent(theme) },
value: brontoVegaNeutral(theme),
},
legend: null,
},
};
brontoVegaAccent(theme) is the resolved --accent and brontoVegaNeutral(theme)
the resolved --text-dim; re-read both when the theme toggles. A
legend for an accent-rationed chart keys the two
with inline swatch colours (style="--chart-color: …"), not with categorical
slots.
Selecting the themed ramp in a spec
The config registers the ramps as named ranges, so a quantitative encoding
opts in with scale: { range: 'heatmap' } (or 'ramp' / 'diverging') — the
range name, not a colour scheme:
{
mark: 'rect',
encoding: {
x: { field: 'x', type: 'nominal' },
y: { field: 'y', type: 'nominal' },
color: { field: 'v', type: 'quantitative', scale: { range: 'heatmap' } },
},
}
Use
scale: { range: 'heatmap' }, notscale: { scheme: 'heatmap' }.scheme:looks up a registered Vega/d3 scheme by name and throws for'heatmap'(no such scheme) — the ramp is a customrangethe bronto config defines, addressed by range name. Aquantitativecolour encoding already defaults torange.heatmap; name it explicitly only when a chart has several quantitative scales and you want a specific one ('diverging'for a signed domain around a neutral centre).
Sequential & diverging ramps invert by theme
range.heatmap / ramp / ordinal is a single-hue ramp that runs pale → deep
as the value rises in light theme, and deep → pale in dark theme (the bg flips,
so the ramp flips to stay legible against it). Two consequences:
- Don't hard-code ink on a heatmap cell. A fixed black (or white) label is
readable at one end of the ramp and invisible at the other — and the readable
end swaps between themes. Either omit per-cell labels and rely on the fallback
ui-table, or compute the label colour from the cell's luminance at render time. bronto themes the ramp; it can't know your data domain, so it does not ship a cell-ink helper. - A CSS gradient key won't pixel-match the Vega ramp. A native
ui-legend--gradienttrack is interpolated in OKLCH; Vega interpolates itsrange.*ramp in d3's RGB space. They share endpoints but drift in the mid-tones, so a continuous gradient key placed beside a Vega heatmap will not match its mid cells exactly. Use a stepped legend (one swatch per band, each from the samecharts.jsonramp stop) when the key sits next to the chart.
Annotate a chart
Vega renders to SVG, so the annotation layer composes onto it
exactly as in the Mermaid recipe: render to a
frozen SVG (vega-embed's view.toSVG(), or the Vega CLI), read the target mark's
box, and paste a <g class="ui-annotation"> computed with
@ponchia/ui/annotations. The same caveat applies — Vega's internal SVG (element
ids, the role/aria structure, the scene transform) is not a public
contract, so pin your Vega version and key off the data, not generated ids.
Scope
bronto owns the theme config — gated structurally by check:vega (every colour
slot resolves, both themes, no var() leaks, every range.* ramp populated),
and by the unit suite (check:unit, via the vega/vega-lite dev deps) which
asserts the colours actually land on a rendered chart — and the annotation
geometry. It does not own Vega's grammar, its rendering, or its internal SVG —
those stay Vega's, and the chart is a documented composition, not a shipped
runtime binding.