D2
D2 compiles a diagram script to SVG (Go CLI or the
@terrastruct/d2 WASM build). Like the Mermaid integration,
@ponchia/ui doesn't render diagrams — it themes them from your tokens and
lets you annotate the result. Two things ship:
@ponchia/ui/d2— helpers that produce on-brand D2 theme overrides.@ponchia/ui/d2.json— the resolved slot → hex maps, for any consumer.
D2 stays the consumer's renderer; this is config only, and D2 is not a dependency.
Theme a diagram
D2's theme is a compact set of named colour slots. Override them with the bronto
palette by prepending a vars block to your diagram source — brontoD2Vars()
returns exactly that block (both light and dark):
import { brontoD2Vars } from '@ponchia/ui/d2';
const source = brontoD2Vars() + `
api -> db: query
db: Store { shape: cylinder }
`;
// → render `source` with the D2 CLI or @terrastruct/d2
If you render through D2's Go or WASM API instead of source, pass the slot map to
its themeOverrides / darkThemeOverrides:
import { brontoD2Overrides } from '@ponchia/ui/d2';
brontoD2Overrides('dark'); // { N1: '#e6e6e6', B1: '#a0a0a0', … }
For a build step or non-JS host, read @ponchia/ui/d2.json directly.
file:// portability. A report opened from disk (
file://) cannotimportthe@ponchia/ui/d2module norfetch('…/d2.json')— the browser blocks both across thenull/file origin (CORS), exactly as with Vega and Mermaid. This is rarely an issue for D2, whose native path is build-time pre-rendering to a frozen SVG (no client runtime — see below), but if you do theme D2 in the browser overfile://, inline the slot map (generate the paste-ready literal withnpm run emit:theme d2 light/dark, and guard it against token drift withnpm run emit:theme:check <file>) rather than importing it. Overhttp(s)theimport/fetchforms both work.
Why resolved colours, not var(--x)
D2 compiles to a frozen SVG in Go/WASM — there is no client CSS cascade, so a
var(--accent) could never resolve. The maps therefore ship resolved hex per
theme, projected from the same token source as
tokens/resolved.json / charts.json. Each slot is set
explicitly (D2 does not derive a ramp from one seed). Because the output is a
static SVG, build-time pre-rendering is D2's native path — ideal for the
report layer.
What the slots paint
The mapping keeps a diagram monochrome by default — the rationed accent is not spent on borders, edges, or shapes the author never marked:
| Slots | Paint | bronto mapping |
|---|---|---|
N1–N3 |
Text · muted · subtle | --text · --text-soft · --text-dim |
N4–N5 |
Strong / regular lines | --line-strong · --line |
N6–N7 |
Subtle background · canvas | --panel-soft · --bg |
B1 |
Shape borders and connections (edges) | --edge (a connection carries meaning: 3:1, WCAG 1.4.11) |
B2–B3 |
Muted borders | --line |
B4–B6 |
Container fills (outer → leaf) | --panel-soft · --bg-elevated · --panel |
AA* / AB* |
Alternative-accent ramps (special-shape fills, class/sql headers) | kept neutral |
The alt-accent ramps are deliberately neutral: D2 spends them on special shapes (cylinders, class/sql-table headers) by default, so mapping them to the accent would colour shapes you never marked. Keeping them neutral is what holds the monochrome default.
Spending the accent
To emphasise a node, opt it into a class that sets the accent explicitly — the accent appears only where you ask for it:
classes: {
accent: {
style: { fill: "#d71921"; font-color: "#ffffff"; stroke: "#b2151b" }
}
}
alert: Alert { class: accent }
Use the resolved --accent / --accent-strong hex (from
tokens/resolved.json, per theme) for the fill / stroke
so the emphasis matches the rest of the surface. Reserve it for the one thing a
reader must not miss.
The label on an accent fill uses
--on-accent, not--accent-text. A filled-accent node needs on-accent ink for itsfont-color— the resolved--on-accenttoken (white on the light accent, black on the dark accent; ≥ 4.5:1, gated in contrast.md). Do not reach for--accent-textby name: that is the inverse token — accent-coloured text for a neutral background (it resolves to--accent-strong, ~1.3:1 on the accent fill, an unreadable label). The literal#ffffffabove is--on-accentfor the light theme; pull the per-theme hex fromtokens/resolved.json.
Frozen inline SVG (no D2 runtime)
A static, PDF-first report often has no D2 binary in its pipeline. When you only
need a few boxes and arrows, hand-author a token-themed inline <svg> instead
of running D2 — the same frozen-figure route the report chart
recipe uses. Paint each element from the
token that the live theme map would have resolved, so the frozen diagram still
re-skins (drive fills from var(--token) in the inline style/attributes, or
paste the resolved hex from tokens/resolved.json for a
file:// PDF):
| Diagram element | bronto token | (live D2 slot it mirrors) |
|---|---|---|
| Node fill | --panel |
B4–B6 container fills |
| Node border | --edge |
B1 |
| Node label | --text |
N1 |
| Edge / connector + arrowhead | --edge |
B1 |
| Edge label | --text-soft |
N2 |
| Cluster / container fill | --panel-soft |
B4 |
| Accent (emphasised) node fill · its label | --accent · --on-accent |
the accent class above |
<svg viewBox="0 0 320 120" role="img" aria-labelledby="flow-title">
<title id="flow-title">Ingest → Queue → Worker</title>
<!-- edge -->
<line x1="92" y1="40" x2="128" y2="40" stroke="var(--edge)" marker-end="url(#arrow)" />
<line x1="220" y1="40" x2="256" y2="40" stroke="var(--edge)" marker-end="url(#arrow)" />
<defs>
<marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L8,4 L0,8 z" fill="var(--edge)" />
</marker>
</defs>
<!-- neutral node -->
<rect x="16" y="22" width="76" height="36" rx="6" fill="var(--panel)" stroke="var(--edge)" />
<text x="54" y="44" text-anchor="middle" fill="var(--text)" font-size="12">Ingest</text>
<!-- accent (emphasised) node: on-accent ink on the accent fill -->
<rect x="128" y="22" width="92" height="36" rx="6" fill="var(--accent)" stroke="var(--accent-strong)" />
<text x="174" y="44" text-anchor="middle" fill="var(--on-accent)" font-size="12">Queue</text>
<rect x="256" y="22" width="64" height="36" rx="6" fill="var(--panel)" stroke="var(--edge)" />
<text x="288" y="44" text-anchor="middle" fill="var(--text)" font-size="12">Worker</text>
</svg>
For anything larger or graph-laid-out, run D2 with the theme map and freeze its output — don't hand-lay a complex graph.
Tokenize D2 output — one inline SVG that re-skins live
A frozen D2 SVG carries resolved hex, so a dynamic (screen-only) report
would need a light SVG and a dark SVG and JS/CSS to swap them — and the hidden
twin is dead weight. Instead, post-process the rendered SVG's colours back into
tokens, and ONE inline SVG re-skins live when data-theme flips (this is the
inverse of the resolved-hex rule above: it only works for inline SVG in a
themed page, never for file:///PDF artifacts or <img> embeds):
- Render light only with the theme map (
brontoD2Vars()prepended). - D2 emits each colour twice: as inline
fill="#hex"/stroke="#hex"AND as class rules in an embedded<style>block — not just.fill-*/.stroke-*: there are also.color-*and.background-color-*rules (they carry the same hex and trip any raw-colour gate). The style rules win over the inline attributes, so strip ALL hex-bearing rules from the<style>first — only then do attribute rewrites take effect. - Rewrite the inline hex →
var(--token)using the slot table above (N1→--text,N4→--line-strong,B1→--edge,N6/B4→--panel-soft,B6→--panel, accent class fill→--accent, its ink→--on-accent, …). - Leave
<mask>fill="black"/"white"keywords alone — that is a luminance mask, not a colour. - Make the outer
<svg>fluid (dropwidth/height, keepviewBox) and inject<title>+<desc>withrole="img" aria-labelledbybefore inlining.
The result follows the page theme with zero swap machinery, and avoids the
visual-QA traps of the two-SVG approach (a display:none twin is easy to
flag as a blank figure).
Avoid
tooltip:and|mdmarkdown shapes in frozen report SVGs. Both make D2 embed GitHub-Primer styling that survives tokenization: tooltips render Octicon info-icons and markdown text ships Primer CSS, each full of foreignvar(--color-*)references and extra hex (#2e3346-class values outside the theme map). Fold tooltip text into the node label and use plain labels orshape: textinstead — or strip the tooltip appendix from the SVG before inlining.
Fit to small screens
D2 emits an SVG with explicit width/height from its layout, so on a narrow
screen it overflows unless you make it fluid. Two build-time options:
- Scale to fit — drop the fixed
width/heightattributes (keep theviewBox) so the SVG shrinks to its container. Best for diagrams that stay legible at smaller sizes. - Scroll a wide diagram — wrap it in
overflow-x: autoso a large diagram scrolls inside its box instead of pushing the page wide, the same pattern the report layer uses for wide figures.
.diagram-scroll {
overflow-x: auto;
}
.diagram-scroll svg {
max-inline-size: 100%;
block-size: auto;
}
Render non-sketch at a fixed scale for predictable boxes.
Annotate a diagram
D2 output is SVG, so the annotation layer composes onto it
exactly as in the Mermaid recipe: render to a
frozen SVG (the D2 CLI d2 in.d2 out.svg, or @terrastruct/d2 in a build
script), read the target shape's box, and paste a <g class="ui-annotation">
computed with @ponchia/ui/annotations. The same caveats apply — D2's internal
SVG (element ids, the root transform) is not a public contract, so pin your D2
version, render non-sketch, account for pad / scale, and avoid
animate-interval (a multi-board animated SVG would not track a single overlay).
Scope
bronto owns the theme map (gated: every slot resolves to a colour, both themes,
no var() leaks) and the annotation geometry. It does not own D2's rendering,
its internal SVG, or its CLI — those stay D2's, and the overlay is a documented
composition, not a shipped runtime binding.