Docs Reports & data

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://) cannot import the @ponchia/ui/d2 module nor fetch('…/d2.json') — the browser blocks both across the null/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 over file://, inline the slot map (generate the paste-ready literal with npm run emit:theme d2 light / dark, and guard it against token drift with npm run emit:theme:check <file>) rather than importing it. Over http(s) the import/fetch forms 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 its font-color — the resolved --on-accent token (white on the light accent, black on the dark accent; ≥ 4.5:1, gated in contrast.md). Do not reach for --accent-text by 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 #ffffff above is --on-accent for the light theme; pull the per-theme hex from tokens/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):

  1. Render light only with the theme map (brontoD2Vars() prepended).
  2. 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.
  3. 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, …).
  4. Leave <mask> fill="black"/"white" keywords alone — that is a luminance mask, not a colour.
  5. Make the outer <svg> fluid (drop width/height, keep viewBox) and inject <title> + <desc> with role="img" aria-labelledby before 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 |md markdown 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 foreign var(--color-*) references and extra hex (#2e3346-class values outside the theme map). Fold tooltip text into the node label and use plain labels or shape: text instead — 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/height attributes (keep the viewBox) 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: auto so 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.