Skip to content

Repository files navigation

esg-ui-kit

→ Storybook · → Live demo

React components for corporate sustainability data — GHG inventories, emission intensity, energy flows, and disclosure-grade report previews.

npm ci storybook

Why it exists

There are excellent general component libraries, and this is not competing with any of them. Radix already solves the popover; Recharts already draws the bar. What none of them know is what the bar means.

A greenhouse gas inventory has rules that a chart library cannot enforce because it has never heard of them:

  • Biogenic CO₂ is disclosed outside the scopes. It is not a fourth stack segment and it is not part of the total. A stacked bar that adds it in is wrong in a way that looks completely normal.
  • Scope 2 is reported twice, location-based and market-based, and exactly one of them belongs in a headline figure. A single "Scope 2" number silently picks one.
  • Scope 3 that carries no category is not zero. It has to stay visible outside the categorised breakdown rather than vanish from it.
  • A figure from an unverified factor set is not a disclosure. That qualifier has to travel with the number, not sit in a footnote.
  • A capped search result is not a complete one. Twenty-five of ninety-eight matches rendered as "25 results" reads exactly like a search that found twenty-five.
  • A rise is bad. Emissions invert the colour convention every financial dashboard uses.

Each of those is a place where a correct-looking interface states something the data does not support. This kit encodes them in the types and in the components, so getting them wrong takes effort.

Where it sits

GhgAccounting          calculation — GHG Protocol / ISO 14064-1, source-cited factors
      ↓                github.com/agirgol/carbon-accounting-dotnet
mcp-carbon-server      protocol surface — MCP tools, schemas, transport
      ↓                github.com/agirgol/mcp-carbon-server
esg-ui-kit             this repository — the visual layer

The kit's types mirror mcp-carbon-server's contracts one for one: same field names, same string values, same nullability. A tool result renders without an adapter.

const inventory = await callTool("build_inventory", { lines, gwpSet: "Ar6" });

<ScopeBreakdownChart inventory={inventory} />   // no mapping step

That is also why the enums look like "KilowattHour" rather than "kWh" — the server sends .NET member names, and turning those into published symbols is this kit's job, not something each consumer should reimplement.

ReportPreview closes the loop the other way. Its six readiness checks are the ones mcp-carbon-server frames for a language model in its disclosure_review prompt, run arithmetically instead — the prompt tells a model what to look for, this counts. One standard, two implementations: one reasoning, one deterministic.

The demo

apps/demo — live — is an inventory workbench that runs the chain end to end:

browser → Next.js → MCP session → mcp-carbon-server → GhgAccounting

Type a quantity, and calculate_emissions and build_inventory run over MCP; the results render through ScopeBreakdownChart, GhgInventoryTable, IntensityMetricCard and ReportPreview with no adapter between them. apps/demo/lib/inventory.ts is the whole integration.

With no MCP_CARBON_URL configured it falls back to illustrative sample data — and says on the page which of the two it is showing, because a demo that serves canned figures while implying a live backend is exactly the unsupported claim this kit exists to make harder.

Install

npm install @agirgol/esg-ui-kit
import { DataQualityBadge } from "@agirgol/esg-ui-kit";
import "@agirgol/esg-ui-kit/styles.css";

<DataQualityBadge quality="Secondary" verification="NeedsReview" />;

Peer dependencies: React 18.3+ or 19.

The stylesheet will not touch your page

A component library that ships a global reset is a library that edits pages it was only supposed to render inside of. Three things prevent that here:

  • No preflight. Tailwind's reset is not imported. Your headings, lists and form controls are untouched.
  • Prefixed utilities. Every class is esg:*. If you run Tailwind yourself with a different scale, you cannot resize this kit's internals and it cannot resize yours.
  • Cascade layers. Everything sits in the esg layer, which loses to any unlayered rule. Overriding a component is plain CSS, not a specificity fight.

There is exactly one universal selector in the output — Tailwind's *, ::before, ::after, ::backdrop block, which seeds its --tw-* custom properties. It declares no visual property, so it paints nothing; it is not a reset.

CI asserts all of this on every build via scripts/assert-stylesheet-contained.mjs: the layer wrapper, zero unprefixed classes, zero bare element selectors, and no universal rule setting anything other than a custom property. The checker has its own failing fixture, because a containment check that cannot fail is decoration.

Theming

Redefine the tokens on :root. They are plain custom properties, and the utilities reference them by var() rather than baking in hex, so an override actually takes:

:root {
  --esg-scope-1: #b8542a;
  --esg-font-sans: "Söhne", system-ui, sans-serif;
}

Dark mode follows prefers-color-scheme by default and obeys data-theme="light" | "dark" on <html> when you want a manual switch. Both directions work; neither is required.

Components

Component What it does Status
ScopeBreakdownChart Scope 1/2/3 part-to-whole, both scope 2 methods, scope 3 drill-down ✅ shipped
IntensityMetricCard Emission intensity per unit of output, with an inverted delta ✅ shipped
GhgInventoryTable Activity lines — sort, filter, CSV export, scope 2 double-count guard ✅ shipped
DataQualityBadge Data quality tier and factor-set verification status ✅ shipped
EmissionFactorPicker Catalog combobox that reports matched vs returned and resolves provenance ✅ shipped
EnergyFlowSankey Energy carrier flow, with unbalanced nodes reported rather than smoothed ✅ shipped
ScenarioCompareSlider Baseline against modelled pathways, no interpolation between them ✅ shipped
ReportPreview Disclosure preview with the six GHG Protocol readiness checks ✅ shipped

The pieces they are built from — Panel, ChartFrame, Figure, Delta, Legend, StackedBar — are exported too, so a consumer's own readout can sit in the same visual system rather than approximating it.

ChartFrame is worth calling out: its table prop is required. Every chart in this kit has a table view because a chart cannot be written without one — which is also what licenses the palette, since three light-mode series colours sit below 3:1 against the surface and are legal only with a relief channel.

Design

The look is instrument: an engineering readout, not a marketing dashboard. Chrome recedes to hairlines and graphite ink so the only saturated colour on screen is data. Figures use tabular numerals because a reader scans a column to compare magnitudes, and proportional digits make 111 look narrower than 999. Monospace is reserved for machine identifiers — factor ids, dataset ids — where character-level comparison is the point.

Every categorical colour was validated under protanopia and deuteranopia simulation rather than chosen by eye, and the first palette proposed here failed: making Scope 3 violet collapses against Scope 2 blue in dark mode at ΔE 1.9. The runs, the failures and the reasoning are in docs/color.md — and rendered live, with the rejected pair shown side by side, under Foundations → Colour in the Storybook.

Two domain rules sit above the measurements: no emissions series is green (in an ESG interface green reads as "good", which is the opposite of what an emissions figure means), and scope hues are semantic and fixed — combustion warm, purchased electricity blue, value chain aqua — so filtering a scope never repaints the others.

The green rule is about CO₂e figures specifically. EnergyFlowSankey carries energy rather than emissions, so a renewable carrier does take the green slot there — it is a category, not a verdict on a quantity.

Accessibility

  • Colour is never the only carrier. Quality is a step count, status is an icon plus a word, series get direct labels.
  • Every chart has a table view — ChartFrame takes it as a required prop.
  • axe runs over every component in the test suite, in both chart and table views and in both locales, so pnpm test fails on a violation. The checker ships a case proving it can fail. Two real defects turned up the first time it ran: a focusable badge nested inside a listbox option, and a slider whose accessible name sat on the wrong element.
  • Colour contrast is outside jsdom's reach — there is no layout and nothing is painted — so those rules are disabled explicitly rather than left to report false passes. Contrast is measured instead against each token's own surface, under CVD simulation, in docs/color.md.
  • Both themes are selected palettes validated against their own surface, not an automatic inversion.

Localisation

English and Turkish ship in the box, through a context provider with no i18n dependency — a component kit that requires i18next has made a framework choice on the consumer's behalf.

<EsgProvider locale="tr">…</EsgProvider>

What is deliberately not translated: factor ids, dataset names, publishers, unit symbols and gas formulae. A Turkish disclosure still has to cite DESNZ and kWh by the names an auditor can look up.

Development

pnpm install
pnpm storybook     # component workshop, localhost:6006
pnpm demo          # inventory workbench, localhost:3000
pnpm test
pnpm build

Requires Node 20+ and pnpm.

Licence

MIT

About

React components for GHG Protocol sustainability reporting. Scope 1/2/3 breakdown, emission intensity, inventory tables and disclosure-grade report previews, typed against the mcp-carbon-server contracts. Ships no global CSS.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages