diff --git a/.github/workflows/node-build-committed-files.yml b/.github/workflows/node-build-committed-files.yml
index 781902d1..c8784038 100644
--- a/.github/workflows/node-build-committed-files.yml
+++ b/.github/workflows/node-build-committed-files.yml
@@ -37,3 +37,34 @@ jobs:
echo "No changes found after generating."
exit 0
fi
+
+ design-tokens:
+ name: "Check design tokens match src/index.css"
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version-file: .nvmrc
+ cache: "npm"
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Regenerate design tokens
+ run: npm run tokens:build
+
+ - name: Compare Results
+ run: |
+ if git diff --quiet HEAD -- docs/design/tpr-design-tokens.json; then
+ echo "Design tokens match src/index.css."
+ else
+ echo "::error::docs/design/tpr-design-tokens.json is out of date with src/index.css. Run 'npm run tokens:build' and commit the result."
+ git --no-pager diff HEAD -- docs/design/tpr-design-tokens.json
+ exit 1
+ fi
diff --git a/docs/design/README.md b/docs/design/README.md
new file mode 100644
index 00000000..79515b9d
--- /dev/null
+++ b/docs/design/README.md
@@ -0,0 +1,48 @@
+# TPR design reference
+
+Design files for the Transition Pathways Repository, kept next to the code they describe. Nothing in this folder is part of the website: it is never built, deployed, or shown to users.
+
+| File | What it is |
+| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [`tpr-design-tokens.json`](tpr-design-tokens.json) | Every brand color, plus type sizes, weights, corner radius, and shadows, in a format Figma can import (via the Tokens Studio plugin). |
+| [`tpr-ui-kit.html`](tpr-ui-kit.html) | A visual reference page showing every reusable interface component: badges, buttons, cards, tables, and so on, each labeled with the code names developers use. |
+
+## Opening the UI kit
+
+GitHub shows HTML files as code rather than as a page. To view it:
+
+1. Open [`tpr-ui-kit.html`](tpr-ui-kit.html) on GitHub.
+2. Click the **Download raw file** button (the downward arrow, top right of the file).
+3. Double-click the downloaded file. It opens in your browser.
+
+The page's last section, **Using this in Figma**, walks through importing the colors and components.
+
+## For the designer: what stays up to date by itself, and what doesn't
+
+### ✅ Automatic: the colors in `tpr-design-tokens.json`
+
+The colors in this file are copied straight from the app's code by a script, never typed by hand. Every proposed code change is checked automatically on GitHub: if a developer changes a color and this file no longer matches, the change is blocked until they update it.
+
+**What this means for you:** the colors in this file always match the app's code on the same branch. You can trust it without double-checking. This folder currently lives on the `epic/v2` branch, so it reflects the v2 redesign rather than the version users see today.
+
+**What it doesn't do:** it doesn't update Figma. When colors change, re-import this file into Tokens Studio to pick up the new values. Checking this file's history on GitHub (the **History** button) shows you when it last changed.
+
+### ✋ Manual: everything else
+
+These parts are maintained by hand, so they can fall behind the code. When the app's look changes, someone needs to update them.
+
+| What | Why it's manual | When to update it |
+| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **The UI kit page** (`tpr-ui-kit.html`) | It's a hand-drawn picture of the components, not the components themselves. No script can redraw it. | When a change adds, removes, or restyles a component. Ask the developer making the change to update the kit in the same pull request, or flag it for you. |
+| **Type sizes, weights, corner radius, shadows** in the token file | These are standard defaults that the app's code doesn't define, so there's nothing for the script to copy from. They rarely change. | Only if the team deliberately changes them, for example by adopting a brand typeface. |
+| **The "Last checked" line below** | It records when someone last compared the UI kit against the real app. | Every time the UI kit is updated. |
+| **Your Figma file** | Figma lives outside this repository. | After any change to either file here. |
+
+**Last checked:** October 2026, against the v2 redesign in [RMI/tpr#937](https://github.com/RMI/tpr/pull/937).
+
+> **Note on v2:** items in the UI kit tagged **v2** come from #937, the v2 redesign on the `epic/v2` branch, which had not been merged into `main` when this was written. Once it merges, remove the "not yet live" note at the top of the UI kit and update the line above.
+
+## For developers
+
+- **Changed a color in `src/index.css`?** Run `npm run tokens:build` and commit the updated `docs/design/tpr-design-tokens.json`. CI fails until you do.
+- **Changed how a component looks?** Update `tpr-ui-kit.html` to match (or tell the designer), and update the **Last checked** line above.
diff --git a/docs/design/tpr-design-tokens.json b/docs/design/tpr-design-tokens.json
new file mode 100644
index 00000000..b7abfa85
--- /dev/null
+++ b/docs/design/tpr-design-tokens.json
@@ -0,0 +1,451 @@
+{
+ "color": {
+ "bluespruce": {
+ "value": "#003b63",
+ "type": "color",
+ "description": "Primary brand color. Header, footer, primary headings, card CTA text."
+ },
+ "energy": {
+ "value": "#45cfcc",
+ "type": "color",
+ "description": "Primary accent. Primary buttons, active states, links on hover."
+ },
+ "basalt": {
+ "value": "#58595b",
+ "type": "color",
+ "description": "Primary neutral, used for borders on dark surfaces (e.g. footer border)."
+ },
+ "energy-100": {
+ "value": "#b6f2f1",
+ "type": "color"
+ },
+ "energy-400": {
+ "value": "#7be8e6",
+ "type": "color"
+ },
+ "energy-700": {
+ "value": "#2ab5b2",
+ "type": "color"
+ },
+ "energy-800": {
+ "value": "#118280",
+ "type": "color"
+ },
+ "success-400": {
+ "value": "#5ce5a1",
+ "type": "color"
+ },
+ "success-600": {
+ "value": "#00cc66",
+ "type": "color"
+ },
+ "success-800": {
+ "value": "#00994d",
+ "type": "color"
+ },
+ "error-400": {
+ "value": "#f7988b",
+ "type": "color"
+ },
+ "error-600": {
+ "value": "#df4e39",
+ "type": "color"
+ },
+ "error-800": {
+ "value": "#ab3c2c",
+ "type": "color"
+ },
+ "neutral-50": {
+ "value": "#f4f7f9",
+ "type": "color"
+ },
+ "neutral-100": {
+ "value": "#ecf1f4",
+ "type": "color"
+ },
+ "neutral-200": {
+ "value": "#e4e9ee",
+ "type": "color"
+ },
+ "neutral-300": {
+ "value": "#d6dde2",
+ "type": "color"
+ },
+ "neutral-400": {
+ "value": "#bdc9d3",
+ "type": "color"
+ },
+ "neutral-500": {
+ "value": "#b3bcc5",
+ "type": "color"
+ },
+ "neutral-600": {
+ "value": "#86929d",
+ "type": "color"
+ },
+ "neutral-700": {
+ "value": "#646e77",
+ "type": "color"
+ },
+ "neutral-800": {
+ "value": "#3b444d",
+ "type": "color"
+ },
+ "neutral-900": {
+ "value": "#1d2328",
+ "type": "color"
+ },
+ "slate": {
+ "value": "#92aec5",
+ "type": "color"
+ },
+ "pine": {
+ "value": "#135908",
+ "type": "color"
+ },
+ "donate": {
+ "value": "#00cc66",
+ "type": "color"
+ },
+ "solar": {
+ "value": "#ffcb00",
+ "type": "color"
+ },
+ "deficient": {
+ "value": "#e04d39",
+ "type": "color"
+ },
+ "coal": {
+ "value": "#323232",
+ "type": "color"
+ },
+ "calm": {
+ "value": "#e1e6f0",
+ "type": "color"
+ },
+ "solar-100": {
+ "value": "#ffeda9",
+ "type": "color"
+ },
+ "solar-200": {
+ "value": "#ffe480",
+ "type": "color"
+ },
+ "solar-400": {
+ "value": "#ffdd00",
+ "type": "color"
+ },
+ "solar-800": {
+ "value": "#c85c19",
+ "type": "color"
+ },
+ "pinishgreen-100": {
+ "value": "#b2e6b2",
+ "type": "color"
+ },
+ "pinishgreen-200": {
+ "value": "#99d699",
+ "type": "color"
+ },
+ "pinishgreen-400": {
+ "value": "#408026",
+ "type": "color"
+ },
+ "pinishgreen-800": {
+ "value": "#11431b",
+ "type": "color"
+ },
+ "rmipurple-100": {
+ "value": "#c7c4e2",
+ "type": "color"
+ },
+ "rmipurple-200": {
+ "value": "#a59fce",
+ "type": "color"
+ },
+ "rmipurple-400": {
+ "value": "#7b76ad",
+ "type": "color"
+ },
+ "rmipurple-800": {
+ "value": "#3e3969",
+ "type": "color"
+ },
+ "rmiblue-100": {
+ "value": "#c9e3f0",
+ "type": "color"
+ },
+ "rmiblue-200": {
+ "value": "#9bcce3",
+ "type": "color"
+ },
+ "rmiblue-400": {
+ "value": "#529cba",
+ "type": "color"
+ },
+ "rmiblue-800": {
+ "value": "#005d7f",
+ "type": "color"
+ },
+ "rmired-100": {
+ "value": "#eec7b7",
+ "type": "color"
+ },
+ "rmired-200": {
+ "value": "#df897b",
+ "type": "color"
+ },
+ "rmired-400": {
+ "value": "#c4151c",
+ "type": "color"
+ },
+ "rmired-800": {
+ "value": "#790000",
+ "type": "color"
+ },
+ "rmipink-100": {
+ "value": "#f0c9de",
+ "type": "color",
+ "description": "Rose family, added in v2 (RMI/tpr#937) for sector-segment badges. Badge fill."
+ },
+ "rmipink-200": {
+ "value": "#e39bc4",
+ "type": "color",
+ "description": "Sector-segment badge border."
+ },
+ "rmipink-400": {
+ "value": "#ba5292",
+ "type": "color"
+ },
+ "rmipink-800": {
+ "value": "#7f0053",
+ "type": "color",
+ "description": "Sector-segment badge text."
+ },
+ "rmigray-100": {
+ "value": "#e6e7e8",
+ "type": "color"
+ },
+ "rmigray-200": {
+ "value": "#a7a9ac",
+ "type": "color"
+ },
+ "rmigray-300": {
+ "value": "#808284",
+ "type": "color"
+ },
+ "rmigray-400": {
+ "value": "#58585b",
+ "type": "color"
+ },
+ "rmigray-500": {
+ "value": "#414142",
+ "type": "color"
+ },
+ "rmigray-600": {
+ "value": "#333333",
+ "type": "color"
+ },
+ "rmigray-700": {
+ "value": "#2a2a2b",
+ "type": "color"
+ },
+ "rmigray-800": {
+ "value": "#231f20",
+ "type": "color"
+ }
+ },
+ "fontFamilies": {
+ "sans": {
+ "value": "ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'",
+ "type": "fontFamilies",
+ "description": "No brand typeface is defined in the codebase yet — this is Tailwind's default system-font stack, inherited as-is."
+ },
+ "mono": {
+ "value": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
+ "type": "fontFamilies"
+ }
+ },
+ "fontSize": {
+ "2xs": {
+ "value": "10px",
+ "type": "fontSizes",
+ "description": "Not a theme token: written as the arbitrary value text-[10px] in code. Used for scope-axis labels and their Show all / Show fewer control (v2)."
+ },
+ "xs": {
+ "value": "12px",
+ "type": "fontSizes"
+ },
+ "sm": {
+ "value": "14px",
+ "type": "fontSizes"
+ },
+ "base": {
+ "value": "16px",
+ "type": "fontSizes"
+ },
+ "lg": {
+ "value": "18px",
+ "type": "fontSizes"
+ },
+ "xl": {
+ "value": "20px",
+ "type": "fontSizes"
+ },
+ "2xl": {
+ "value": "24px",
+ "type": "fontSizes"
+ },
+ "3xl": {
+ "value": "30px",
+ "type": "fontSizes"
+ },
+ "4xl": {
+ "value": "36px",
+ "type": "fontSizes"
+ },
+ "5xl": {
+ "value": "48px",
+ "type": "fontSizes"
+ }
+ },
+ "lineHeight": {
+ "xs": {
+ "value": "16px",
+ "type": "lineHeights"
+ },
+ "sm": {
+ "value": "20px",
+ "type": "lineHeights"
+ },
+ "base": {
+ "value": "24px",
+ "type": "lineHeights"
+ },
+ "lg": {
+ "value": "28px",
+ "type": "lineHeights"
+ },
+ "xl": {
+ "value": "28px",
+ "type": "lineHeights"
+ },
+ "2xl": {
+ "value": "32px",
+ "type": "lineHeights"
+ },
+ "3xl": {
+ "value": "36px",
+ "type": "lineHeights"
+ },
+ "4xl": {
+ "value": "40px",
+ "type": "lineHeights"
+ },
+ "5xl": {
+ "value": "48px",
+ "type": "lineHeights"
+ }
+ },
+ "fontWeight": {
+ "normal": {
+ "value": "400",
+ "type": "fontWeights"
+ },
+ "medium": {
+ "value": "500",
+ "type": "fontWeights"
+ },
+ "semibold": {
+ "value": "600",
+ "type": "fontWeights"
+ },
+ "bold": {
+ "value": "700",
+ "type": "fontWeights"
+ }
+ },
+ "borderRadius": {
+ "sm": {
+ "value": "4px",
+ "type": "borderRadius"
+ },
+ "md": {
+ "value": "6px",
+ "type": "borderRadius"
+ },
+ "lg": {
+ "value": "8px",
+ "type": "borderRadius"
+ },
+ "full": {
+ "value": "9999px",
+ "type": "borderRadius"
+ }
+ },
+ "boxShadow": {
+ "sm": {
+ "value": [
+ {
+ "x": "0",
+ "y": "1",
+ "blur": "3",
+ "spread": "0",
+ "color": "rgba(0,0,0,0.1)",
+ "type": "dropShadow"
+ },
+ {
+ "x": "0",
+ "y": "1",
+ "blur": "2",
+ "spread": "-1",
+ "color": "rgba(0,0,0,0.1)",
+ "type": "dropShadow"
+ }
+ ],
+ "type": "boxShadow"
+ },
+ "md": {
+ "value": [
+ {
+ "x": "0",
+ "y": "4",
+ "blur": "6",
+ "spread": "-1",
+ "color": "rgba(0,0,0,0.1)",
+ "type": "dropShadow"
+ },
+ {
+ "x": "0",
+ "y": "2",
+ "blur": "4",
+ "spread": "-2",
+ "color": "rgba(0,0,0,0.1)",
+ "type": "dropShadow"
+ }
+ ],
+ "type": "boxShadow"
+ },
+ "lg": {
+ "value": [
+ {
+ "x": "0",
+ "y": "10",
+ "blur": "15",
+ "spread": "-3",
+ "color": "rgba(0,0,0,0.1)",
+ "type": "dropShadow"
+ },
+ {
+ "x": "0",
+ "y": "4",
+ "blur": "6",
+ "spread": "-4",
+ "color": "rgba(0,0,0,0.1)",
+ "type": "dropShadow"
+ }
+ ],
+ "type": "boxShadow"
+ }
+ }
+}
diff --git a/docs/design/tpr-ui-kit.html b/docs/design/tpr-ui-kit.html
new file mode 100644
index 00000000..c18594cd
--- /dev/null
+++ b/docs/design/tpr-ui-kit.html
@@ -0,0 +1,5186 @@
+
+
+
+
+
+ Pathways UI Kit
+
+
+
+
+
+
+
+
+
Transition Pathways Repository · UI reference
+
Pathways UI Kit
+
+ Every color, type size, and reusable interface component currently
+ shipping in the TPR front end, taken from
+ src/index.css and src/components/. Each
+ entry carries the real Tailwind class names and CSS custom
+ properties, so a Figma file built from this stays traceable back to
+ the code that renders it. Colors and class tags are exact; the
+ previews are hand-built and can drift from the code in small ways.
+ Where a preview and its tags disagree, the tags win.
+
+
+ Component previews sit on a fixed light stage regardless of the
+ reading theme below, so colors always read exactly as they do in the
+ shipped app.
+
+
+ Updated for v2. This edition includes the redesign
+ in
+ RMI/tpr#937
+ (epic/v2): a new rose color family, a sector-segment
+ badge, selectable badges, tabbed detail pages, scope ribbons, data
+ tables, and grouped panels. Entries marked
+ v2
+ come from that pull request, which was
+ still open (not merged) at the time of this
+ snapshot — treat them as the target design, not yet live.
+
+ Defined once as CSS custom properties in a Tailwind
+ @theme block, then consumed everywhere as utility
+ classes (bg-energy,
+ text-rmigray-800, …). Import the hex values
+ below as Figma variables and the two systems stay in lockstep.
+
+
+
Primary
+
+
+
+
+
Bluespruce
+
#003B63
+ --color-bluespruce
+
+
+
+
+
+
Energy
+
#45CFCC
+ --color-energy
+
+
+
+
+
+
Basalt
+
#58595B
+ --color-basalt
+
+
+
+
+
Accent (energy scale)
+
+
+
+
+
energy-100
+
#B6F2F1
+ bg-energy-100
+
+
+
+
+
+
energy-400
+
#7BE8E6
+ bg-energy-400
+
+
+
+
+
+
energy-700
+
#2AB5B2
+ bg-energy-700
+
+
+
+
+
+
energy-800
+
#118280
+ bg-energy-800
+
+
+
+
+
Semantic — success / error
+
+
+
+
+
success-400
+
#5CE5A1
+ bg-success-400
+
+
+
+
+
+
success-600
+
#00CC66
+ bg-success-600
+
+
+
+
+
+
success-800
+
#00994D
+ bg-success-800
+
+
+
+
+
+
error-400
+
#F7988B
+ bg-error-400
+
+
+
+
+
+
error-600
+
#DF4E39
+ bg-error-600
+
+
+
+
+
+
error-800
+
#AB3C2C
+ bg-error-800
+
+
+
+
+
+ Neutral ramp — page backgrounds, borders, disabled states
+
+ Added for sector-segment badges, which needed a family no existing
+ one was free to supply. Sits between rmired and rmipurple in hue so
+ it reads as distinct from both. Same four-step structure: 100 fill,
+ 200 border, 400 mid, 800 text.
+
+
+
+
+
+
rmipink-100
+
#F0C9DE
+ bg-rmipink-100
+
+
+
+
+
+
rmipink-200
+
#E39BC4
+ border-rmipink-200
+
+
+
+
+
+
rmipink-400
+
#BA5292
+ --color-rmipink-400
+
+
+
+
+
+
rmipink-800
+
#7F0053
+ text-rmipink-800
+
+
+
+
+
+
+
+
Typography
+
+ No brand typeface is defined anywhere in the codebase — no
+ @font-face, no webfont link. Every screen renders on
+ the browser's own system font. That's a real gap, not a hidden
+ setting: worth a deliberate decision, not an assumption.
+
+
+
+ Current stack (unset by design intent):
+ ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe
+ UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
+ — Tailwind's default, inherited automatically because
+ tailwind.config.ts never overrides
+ fontFamily.
+
+
+
Type scale in use
+
+
+
+ text-5xlPathways48 / 48
+
+
+ text-4xlPathways36 / 40
+
+
+ text-3xlPathways30 / 36
+
+
+ text-2xlPathways24 / 32
+
+
+ text-xlPathways20 / 28
+
+
+ text-lgPathways18 / 28
+
+
+ text-basePathways Repository16 / 24
+
+
+ text-smPathways Repository14 / 20
+
+
+ text-xsPathways Repository12 / 16
+
+
+ text-[10px]Sector · Geographyv210 / —
+
+
+ The 10px size is an arbitrary Tailwind value, not a theme token.
+ It is used only for scope-axis labels (with
+ uppercase tracking-wider font-semibold) and their
+ "Show all" control. Exported as fontSize.2xs in the
+ token file.
+
+
+
+
+
Weights in use
+
+
+ Aa
+
+ font-normal · 400
+
+
+
+ Aa
+
+ font-medium · 500
+
+
+
+ Aa
+
+ font-semibold · 600
+
+
+
+ Aa
+
+ font-bold · 700
+
+
+
+
+
+
+
+
Spacing & elevation
+
+ Also Tailwind defaults (a 4px base unit) — flagged here because
+ components lean on a small, consistent subset of the radius and
+ shadow scale.
+
+
+
Corner radius
+
+
+
+
+ rounded-sm · 4px
+
+
+
+
+
+ rounded-md · 6px
+
+
+
+
+
+ rounded-lg · 8px
+
+
+
+
+
+ rounded-full
+
+
+
+
+
Elevation
+
+
+
+
+ shadow-sm
+
+
+
+
+
+ shadow-md
+
+
+
+
+
+ shadow-lg
+
+
+
+
+
+
+
+
+
Buttons & actions
+
+ Three action patterns cover the whole app: a filled primary button,
+ a bordered icon toggle (comparison tray), and an underlined text
+ action (dropdown menus and scope axes).
+
+ bg-energyhover:bg-energy-700text-whiterounded-mdpx-4 py-2no text size: 16px base
+
+
+
+
+
+
+
+
+
Icon toggle — default / added / blocked
+
PathwayCard.tsx (comparison tray)
+
+ bg-bluespruceborder-neutral-200h-12 w-12
+
+
+ v2: the disabled look now covers two reasons, each with its
+ own label and tooltip — "Comparison full (max 3)" or "Cannot
+ add: no sector in common with your selection". A comparison
+ needs at least one shared sector.
+
+ One component, sixteen semantic variants keyed to a
+ variant prop — plus a transparent "-pub" style used
+ when a value exists in a publication but has no interactive chart
+ data behind it. In v2 a badge can also be a control: pass
+ buttonProps and it renders as a
+ <button>.
+
+
+
+ Default
+ Normative
+ 1.5°C
+ 2024
+ Global
+ ASEAN
+ Vietnam
+ Power
+ Power generation
+ Emissions Intensity
+ Key Feature
+ Global · pub only
+ ASEAN · pub only
+ Vietnam · pub only
+ Power · pub only
+ Metric · pub only
+
+ v2: sectorSegment is new (bg-rmipink-100 text-rmipink-800 border-rmipink-200) — a subdivision of a sector such as "Power generation" or
+ "Energy storage", shown beside sector badges so it needs its own
+ color. The region and country "-pub" variants were fixed: they
+ referenced pinishgreen 500/600/700, which don't exist, and
+ rendered blank. Both now use
+ text-pinishgreen-800 border-pinishgreen-400.
+
+ Selection is shown with a ring and bolder text, never a color
+ change — color already says what kind of value the badge is. The
+ selected badge is moved to the front so it can't hide behind "+N
+ more".
+
+ Every search facet (sector, geography, metric, pathway type,
+ temperature) shares one shell: a pill trigger that reserves space
+ for its widest possible label, and a floating panel for the options.
+
+
+
+
+
+ Sector...
+
+
+ Sector: 3
+
+
+
+
Trigger — inactive / active
+
+ DropdownFacetShell.tsx · v2: open/close shared via
+ useDropdown; clicking the trigger now toggles
+
+ The panel is at least as wide as its trigger. Some facets add
+ a "Show pathways matching Any / All selected" toggle at the
+ top right, or an italic footnote under the list.
+
+
+
+
+
+
+
+
Numeric range slider
+
+ Used on the step-by-step guide pages to pick a temperature range.
+ The track is a gradient across the warming bands, and the parts
+ outside the selected range are covered in gray. (The search page's
+ temperature filter uses two number fields instead.)
+
+ Gradient colors come from the component: up to 1.5°C #2888C9, up
+ to 2.0°C #91CBF2, above that #DF4E39 (#AB3C2C beyond 3.5°C),
+ with color stops at each half degree. The covers are a
+ hard-coded rgb(230 236 239), not a theme color. Shown with
+ bounds of 1.0–3.5°C.
+
+
+
+
+
+
+
Sentiment & neutral scales
+
+ Ordinal-value bar charts used on pathway detail pages — bars run
+ unfavorable → favorable (sentiment) or are order-only with no
+ judgment implied (neutral). The selected bar is taller; the label
+ below names it.
+
+ Palette runs rmired-400 → rmired-200 → rmired-100 →
+ neutral-500 → success-400 → success-600 → success-800,
+ reversed when the first value is the good one. Three-step
+ scales use rmired-400, neutral-500, success-600. "No
+ information" shows the default badge instead of a label.
+
+ Geographies:
+ Filled badges indicate that data is available for
+ visualization and download in this tool. Outlined badges
+ indicate the data for this geography is available in the
+ source publication, but not currently in this tool.
+
+
+
Tooltip
+
+ TextWithTooltip.tsx — portal-rendered to <body>,
+ positioned on hover/focus
+
+ v2: the trigger can be a button (as="button",
+ cursor-pointer) when the tooltipped item is also
+ a control, such as a selectable badge. The default remains a
+ focusable span with cursor-help.
+
+ The flagship composite — every token and component above assembles
+ here. This is the single highest-value component for a designer to
+ have pixel-accurate in Figma, since it repeats on every search
+ result.
+
+
+
+
+
+
Normative Pathway
+
+
+ 1.5°C
+
+
+
+ Net Zero By2050
+
+
+
+
+
+
+
IEA: Net Zero Emissions Scenario
+
+ A normative IEA scenario mapping a technically feasible
+ path to net-zero CO₂ emissions from the energy sector by
+ 2050.
+
+ The temperature cell's color comes from
+ getTemperatureColor: ≤1.5°C pinishgreen-100, ≤2.0
+ solar-100, ≤2.5 solar-200, ≤3.0 rmired-100, ≤3.5 rmired-200,
+ hotter rmired-400. The add-to-comparison button only appears
+ while the comparison tray is open. In the app the card's width
+ is set by the results grid (three columns on wide screens).
+
+
+
+
+
+
+
Header & footer
+
+ Both use the RMI logo image (/RMILogo-white.svg); the
+ dashed "RMI" boxes below stand in for it.
+
+ The pathway detail page is now split into four tabs: At a glance,
+ Overview, Timeseries, and Scope & Granularity. The active tab is
+ saved in the URL (?tab=), so a link opens the same tab.
+ Arrow keys, Home, and End move between tabs.
+
+ A sticky bar under the page title that lets readers narrow the page
+ to one sector and one geography. It holds a row of selectable badges
+ per axis and, on the detail page, the tab list. Once the title
+ scrolls away, a slim Bluespruce bar slides in to restate it.
+
+
+
+
+
+
+ IEA: Net Zero Emissions Scenario
+ Normative20501.5°C
+
+ Labels use
+ text-[10px] font-semibold uppercase tracking-wider
+ text-rmigray-500. Each axis always has a value — there's no "clear" state.
+ The condensed bar's height animates in (200ms) and is skipped
+ for reduced motion.
+
+
+
+
+
+
+
+
+ IEA NZEACE ATSNGFS Net Zero
+
+
+
+ Sector
+
+
+
+
+
+
+ Geography
+
+
+
+
+
+
+ Publishers do not agree on what Southeast Asia covers:
+ only IEA NZE includes Timor-Leste. Figures below are not
+ exactly like-for-like.
+
+ One shared sector row for all columns. Geography gets one
+ dropdown per pathway, lined up under its column, because each
+ publisher names places differently.
+
+
+
+
+
+
+
+
+
+
+ Global
+
+
+ Southeast Asia
+
+
+ Africa
+
+
+ India
+
+
+
+ Filled badges indicate that data is available for
+ visualization and download in this tool. Outlined badges
+ indicate the data for this geography is available in the
+ source publication, but not currently in this tool.
+
+ Options reuse the geography badges; outlined ("-pub") ones
+ have no chart data. The footnote is
+ GEOGRAPHY_AVAILABILITY_TOOLTIP, the same text as
+ the Geographies tooltip on the pathway card.
+
+
+
+
+
+
+
+
+
Data tables v2
+
+ Two new tables share one style: a Bluespruce header row,
+ zebra-striped body, and the first cell styled as a row heading.
+ Columns that would repeat the same value on every row (such as
+ Sector, once a sector is selected) are hidden automatically.
+
+ Segment and geography cells render as badges (geography shows
+ 3, then "+N more"). Placeholder values — "Not covered",
+ "Unspecified", "No information" — stay as plain text, and a
+ "Not covered" row is plain text throughout.
+
+
+
+
+
+
+
+
+
+
+
Dependency
+
Evidence type
+
Constraint
+
+
+
+
+
Grid expansion
+
+ Quantitative
+
+
Transmission build-out must triple by 2035.
+
+
+
Critical minerals
+
+ Qualitative
+
+
+ Assumes supply chains scale without price shocks.
+
+ Pill colors listed as fill / text / border. The pill shape
+ matches Badge exactly but is its own component. Row content
+ above is sample text.
+
+
+
+
+
+
+
+
+
Section panels v2
+
+ The page's main content blocks now use one pattern: a light gray
+ panel with a title, holding white cards in a grid. Key features,
+ core drivers, coverage, and plots all follow it, so a tab reads as
+ one system.
+
+
+
+
+
+
Assumptions & Trends Overview
+
+
+
Policies
+
+
Policy drivers
+
+ Carbon pricing rises steadily across advanced
+ economies, with phase-out dates for unabated coal.
+
+ Six groups in order: Policies, Emissions, Technology,
+ Investment, Energy System, Other. Each group shows its written
+ driver first, then its feature values. A driver marked not
+ applicable shows "Not a core driver for this pathway." in
+ italic text-rmigray-400. The At a glance tab
+ shows only the first three groups. Pick-list features show
+ every option, with the chosen ones in blue and the rest faded.
+
+
+
+
+
+
+
+
Benchmark Plots
+
+
+ Capacity
+
chart
+
+ GlobalPower generation
+
+
+
+ Emissions intensity
+
+ For this pathway, there is currently no data available
+ for the selected combination of sector, region, and
+ metric.
+
+ One panel per plot type instead of one chart behind a
+ dropdown. Badges under each chart say which geography and
+ sector segment it shows. Metrics with no data anywhere are
+ left out; metrics missing only at this geography keep their
+ panel with an empty message.
+
+
+
+
+
+
+
+
+
Notices & empty states v2
+
+ Plain-text messages that explain what a scope selection is hiding,
+ or why a section is empty. They appear only when something is
+ actually hidden or missing. Clearing a selection is always done in
+ the ribbon, never from these messages.
+
+
+
+
+
+ Showing 3 of 8 rows for Power in Southeast Asia.
+
+
+
+
Scope filter notice
+
ScopeFilterNotice.tsx
+
+ mb-4 text-sm text-rmigray-700
+
+
+
+
+
+
+ These columns are not showing the same geography: IEA NZE
+ shows Global and ACE ATS shows South East Asia. Figures below
+ are not like-for-like.
+
+ This pathway records 4 dependencies in other sectors — clear
+ the sector selection above to see them.
+
+
+
+
+
Empty state — filtered out
+
+ Same shell, second line explains where the rows went
+
+
+ Kept distinct from "nothing recorded" so the page never says a
+ pathway has no data when it's only hidden by a selection.
+
+
+
+
+
+
+
+
+
Using this in Figma
+
Two ways in, pick based on what you need first.
+
+
Option A — colors & type as Figma variables
+
+
+ Install the Tokens Studio plugin (free) in Figma.
+
+
+ Import tpr-design-tokens.json (in the same folder as
+ this page) as a token set. The block below is a short, valid
+ excerpt showing the format.
+
+
+ Use Tokens Studio's "Create Styles/Variables" action to turn every
+ entry into a real Figma color variable and text style, named
+ identically to the CSS tags above.
+
+ The complete file — all 60+ colors, the type scale, weights, radius,
+ and shadow tokens — is tpr-design-tokens.json, in the
+ same folder as this page. Its colors are generated from
+ src/index.css automatically, so they always match the
+ app.
+
+
+
+ Option B — component shapes as editable Figma layers
+
+
+
+ Install the html.to.design plugin in Figma.
+
+
+ Download this file (tpr-ui-kit.html) from the
+ repository, then drop it into the plugin's
+ File tab.
+
+
+ It converts the rendered badges, cards, buttons, and dropdowns
+ straight into native Figma frames, text, and fills — a real
+ starting point to refine rather than a picture to trace.
+
+
+
+
+ Keeping this from going stale: the colors in the
+ token file update automatically, but this page is maintained by
+ hand. When a component changes, update this page in the same pull
+ request, then refresh the Figma copy from it rather than editing
+ Figma directly — otherwise the two will quietly drift apart. See
+ docs/design/README.md for what is automatic and what is
+ manual.
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 62b2d87a..b8227758 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,8 @@
"schema:generate": "npm run schema:generate:types",
"schema:generate:types": "npx ts-node --esm scripts/generate-types.ts && prettier --write \"src/types/**/*.d.ts\"",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "tokens:build": "npx ts-node --esm scripts/build-design-tokens.ts && prettier --write \"docs/design/tpr-design-tokens.json\""
},
"author": "RMI",
"license": "MIT",
diff --git a/scripts/build-design-tokens.test.ts b/scripts/build-design-tokens.test.ts
new file mode 100644
index 00000000..a10e89ff
--- /dev/null
+++ b/scripts/build-design-tokens.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from "vitest";
+import { buildTokens, parseThemeColors } from "./build-design-tokens.js";
+
+describe("parseThemeColors", () => {
+ it("reads --color-* properties from @theme in source order", () => {
+ const css = `
+ @import "tailwindcss";
+ @theme {
+ /* Primary colours */
+ --color-bluespruce: #003b63;
+ --color-energy:#45cfcc ;
+ --font-display: "Inter";
+ }
+ `;
+ expect([...parseThemeColors(css)]).toEqual([
+ ["bluespruce", "#003b63"],
+ ["energy", "#45cfcc"],
+ ]);
+ });
+
+ it("ignores colours outside @theme and inside comments", () => {
+ const css = `
+ :root { --color-outside: #ffffff; }
+ @theme {
+ /* --color-commented: #000000; a { brace } too */
+ --color-inside: #111111;
+ }
+ `;
+ expect([...parseThemeColors(css).keys()]).toEqual(["inside"]);
+ });
+
+ it("combines several @theme blocks, the last declaration winning", () => {
+ const css = `
+ @theme { --color-a: #000001; --color-b: #000002; }
+ @theme { --color-a: #000003; }
+ `;
+ expect(Object.fromEntries(parseThemeColors(css))).toEqual({
+ a: "#000003",
+ b: "#000002",
+ });
+ });
+
+ it("fails loudly rather than emptying the token file", () => {
+ expect(() => parseThemeColors("body { color: red; }")).toThrow(/@theme/);
+ expect(() => parseThemeColors("@theme { --font-x: a; }")).toThrow(
+ /--color-/,
+ );
+ });
+});
+
+describe("buildTokens", () => {
+ const existing = {
+ color: {
+ energy: {
+ value: "#000000",
+ type: "color" as const,
+ description: "Accent.",
+ },
+ removed: { value: "#ffffff", type: "color" as const },
+ },
+ fontSize: { sm: { value: "14px", type: "fontSizes" } },
+ };
+
+ it("replaces colour values and keeps descriptions by name", () => {
+ const colors = new Map([
+ ["bluespruce", "#003b63"],
+ ["energy", "#45cfcc"],
+ ]);
+ expect(buildTokens(colors, existing).color).toEqual({
+ bluespruce: { value: "#003b63", type: "color" },
+ energy: { value: "#45cfcc", type: "color", description: "Accent." },
+ });
+ });
+
+ it("leaves the hand-maintained groups and their order untouched", () => {
+ const tokens = buildTokens(new Map([["energy", "#45cfcc"]]), existing);
+ expect(Object.keys(tokens)).toEqual(["color", "fontSize"]);
+ expect(tokens.fontSize).toBe(existing.fontSize);
+ });
+});
diff --git a/scripts/build-design-tokens.ts b/scripts/build-design-tokens.ts
new file mode 100644
index 00000000..7142fed5
--- /dev/null
+++ b/scripts/build-design-tokens.ts
@@ -0,0 +1,108 @@
+/**
+ * Regenerate the colour tokens in docs/design/tpr-design-tokens.json from the
+ * `@theme` block in src/index.css, so the designer's Figma tokens cannot drift
+ * from the colours the app actually renders.
+ *
+ * Run with `npm run tokens:build`. CI runs it too and fails when the committed
+ * file differs from the output (see node-build-committed-files.yml).
+ *
+ * Only the `color` group is generated. Everything else in the file -- the type
+ * scale, radius and shadows, which are Tailwind defaults src/index.css never
+ * defines -- is hand-maintained and carried over untouched, as is each colour's
+ * optional `description`. A renamed colour therefore loses its description.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const CSS_PATH = path.resolve("src/index.css");
+const TOKENS_PATH = path.resolve("docs/design/tpr-design-tokens.json");
+
+export interface ColorToken {
+ value: string;
+ type: "color";
+ description?: string;
+}
+
+type TokenFile = Record & {
+ color?: Record>;
+};
+
+/**
+ * Every `--color-*` custom property declared inside an `@theme` block, keyed by
+ * the name after `--color-`, in source order.
+ *
+ * Comments are stripped first so a brace or a commented-out declaration inside
+ * one cannot end the block early or leak in as a token. A name declared twice
+ * keeps its last value, as it would in CSS.
+ */
+export function parseThemeColors(css: string): Map {
+ const uncommented = css.replace(/\/\*[\s\S]*?\*\//g, "");
+ const blocks = [...uncommented.matchAll(/@theme\b[^{]*\{([^}]*)\}/g)];
+ if (blocks.length === 0) {
+ throw new Error("No @theme block found in the stylesheet.");
+ }
+
+ const colors = new Map();
+ for (const [, body] of blocks) {
+ for (const [, name, value] of body.matchAll(
+ /--color-([\w-]+)\s*:\s*([^;]+);/g,
+ )) {
+ colors.set(name, value.trim());
+ }
+ }
+ if (colors.size === 0) {
+ throw new Error("The @theme block declares no --color-* properties.");
+ }
+ return colors;
+}
+
+/**
+ * The token file with its `color` group rebuilt from `colors`. The group keeps
+ * its position among the file's top-level keys, and every other key is
+ * returned as it was.
+ */
+export function buildTokens(
+ colors: Map,
+ existing: TokenFile,
+): TokenFile {
+ const previous = existing.color ?? {};
+ const color: Record = {};
+ for (const [name, value] of colors) {
+ const description = previous[name]?.description;
+ color[name] = description
+ ? { value, type: "color", description }
+ : { value, type: "color" };
+ }
+ return { ...existing, color };
+}
+
+function main(): void {
+ if (!fs.existsSync(TOKENS_PATH)) {
+ // The file is the only home of the hand-maintained groups, so it is never
+ // created from scratch here.
+ throw new Error(
+ `${path.relative(process.cwd(), TOKENS_PATH)} is missing. Restore it from git before running this script.`,
+ );
+ }
+ const css = fs.readFileSync(CSS_PATH, "utf8");
+ const existing = JSON.parse(
+ fs.readFileSync(TOKENS_PATH, "utf8"),
+ ) as TokenFile;
+
+ const colors = parseThemeColors(css);
+ const tokens = buildTokens(colors, existing);
+ fs.writeFileSync(TOKENS_PATH, `${JSON.stringify(tokens, null, 2)}\n`);
+ console.info(
+ `Wrote ${colors.size} colour tokens to ${path.relative(process.cwd(), TOKENS_PATH)}.`,
+ );
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ try {
+ main();
+ } catch (e: unknown) {
+ console.error(e instanceof Error ? e.message : e);
+ process.exit(1);
+ }
+}