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. +
+
+ Source src/index.css, src/components/*.tsx + Framework Tailwind CSS v4 + Scope UI atoms & molecules (charts + excluded) + Snapshot Oct 2026, incl. RMI/tpr#937 — not + auto-synced +
+
+ + +
+

Color tokens

+

+ 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 +

+
+
+ 50 +
+
+ 100 +
+
+ 200 +
+
+ 300 +
+
+ 400 +
+
+ 500 +
+
+ 600 +
+
+ 700 +
+
+ 800 +
+
+ 900 +
+
+
+ #F4F7F9#ECF1F4#E4E9EE#D6DDE2#BDC9D3#B3BCC5#86929D#646E77#3B444D#1D2328 +
+ +

Body text ramp — rmigray

+
+
+ 100 +
+
+ 200 +
+
+ 300 +
+
+ 400 +
+
+ 500 +
+
+ 600 +
+
+ 700 +
+
+ 800 +
+
+
+ #E6E7E8#A7A9AC#808284#58585B#414142#333333#2A2A2B#231F20 +
+ +

Secondary brand set

+
+
+
+
+

Slate

+

#92AEC5

+ bg-slate +
+
+
+
+
+

Pine

+

#135908

+ bg-pine +
+
+
+
+
+

Donate

+

#00CC66

+ bg-donate +
+
+
+
+
+

Solar

+

#FFCB00

+ bg-solar +
+
+
+
+
+

Deficient

+

#E04D39

+ bg-deficient +
+
+
+
+
+

Coal

+

#323232

+ bg-coal +
+
+
+
+
+

Calm

+

#E1E6F0

+ bg-calm +
+
+
+ +

+ Badge / plotting families — each ships 100 · 200 · 400 · 800 shades +

+
+
+
+
+

solar-100

+

#FFEDA9

+
+
+
+
+
+

solar-200

+

#FFE480

+
+
+
+
+
+

solar-400

+

#FFDD00

+
+
+
+
+
+

solar-800

+

#C85C19

+
+
+
+
+
+

pinishgreen-100

+

#B2E6B2

+
+
+
+
+
+

pinishgreen-200

+

#99D699

+
+
+
+
+
+

pinishgreen-400

+

#408026

+
+
+
+
+
+

pinishgreen-800

+

#11431B

+
+
+
+
+
+

rmipurple-100

+

#C7C4E2

+
+
+
+
+
+

rmipurple-200

+

#A59FCE

+
+
+
+
+
+

rmipurple-400

+

#7B76AD

+
+
+
+
+
+

rmipurple-800

+

#3E3969

+
+
+
+
+
+

rmiblue-100

+

#C9E3F0

+
+
+
+
+
+

rmiblue-200

+

#9BCCE3

+
+
+
+
+
+

rmiblue-400

+

#529CBA

+
+
+
+
+
+

rmiblue-800

+

#005D7F

+
+
+
+
+
+

rmired-100

+

#EEC7B7

+
+
+
+
+
+

rmired-200

+

#DF897B

+
+
+
+
+
+

rmired-400

+

#C4151C

+
+
+
+
+
+

rmired-800

+

#790000

+
+
+
+ +

+ Rose family — rmipink v2 +

+

+ 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). +

+
+
+
+ +
+
+

Primary button

+

+ DownloadDataset.tsx · PublicationBlock.tsx · PathwaySearch.tsx +

+
+ 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. +

+
+
+
+
+ +
+
+

Text action

+

MultiSelectDropdown.tsx

+
+ underlinedisabled:opacity-50 +
+
+
+
+
+ +
+
+

Compact text action v2

+

ScopeAxisRow.tsx

+
+ text-[10px] underlinetext-rmigray-500hover:text-bluesprucefocus-visible:ring-bluespruce +
+
+
+
+
+ +
+

Badges

+

+ 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 +
+
+

Badge variants

+

+ Badge.tsx — variant prop: default · pathwayType · temperature · + year · geographyGlobal/Region/Country(-pub) · sector(-pub) · + sectorSegment · metric(-pub) · keyFeature +

+
+ rounded-fullpx-2.5 py-0.5text-xs font-mediumborder +
+

+ 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. +

+
+
+
+
+
+ + + +
+
+
+

+ Selectable badge (toggle) v2 +

+

+ BadgeArray.tsx — selected + + onSelect props +

+
+ ring-2 ring-bluesprucering-offset-1 ring-offset-whitefont-semiboldaria-pressed +
+

+ 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". +

+
+
+
+
+
+ Vietnam + Thailand + Malaysia + +4 more +
+
+
+

Badge array — overflow

+

+ BadgeArray.tsx — measures container width, collapses to "+N + more" beyond maxRows +

+

+ v2: the "+N more" tooltip now uses the same display labels as + the visible badges (country names, not codes). +

+
+
+
+ + + + + +
+

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.) +

+
+
+
+
+
+
+
+
+
+ 1.0°C1.5°C2.0°C2.5°C3.0°C3.5°C +
+
+
+
+

Temperature range — active

+

+ NumericRangeSlider.tsx (via StepPageNumericRange.tsx) +

+
+ track: h-2 roundedhandles: h-4 w-4 border bg-whiteactive: border-rmiblue-800 ring-2 ring-rmiblue-100 + shadow-mdresting: border-rmigray-400 shadow-smticks: text-[12px] text-rmigray-500 +
+

+ 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. +

+
+
+
+
+
+
+
+
+
+
+
+
+ Moderate improvement +
+
+

Sentiment scale (7-step)

+

SentimentScale.tsx

+
+ bars: h-1.5 rounded-sm gap-0.5selected: h-3others: opacity-20label: text-xs font-semibold, bar's color +
+

+ 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. +

+
+
+
+
+
+
+
+
+
+
+
+
+
+ Current/legislated policies +
+
+

Neutral scale

+

NeutralScale.tsx

+
+ bars: bg-neutral-200selected: h-3 bg-rmiblue-400label: text-xs font-semibold text-rmiblue-400 +
+
+
+
+
+ +
+

Tooltip & search highlight

+
+
+
+ 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 +

+
+ bg-white border-rmigray-100 roundedtext-xs text-rmigray-500 px-3 py-2shadow-lg max-w-xs opacity-95arrow: border-4 +
+

+ 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. +

+
+
+
+
+ IEA Net Zero Emissions Scenario +
+
+

Search match highlight

+

HighlightedText.tsx

+
+ bg-energy-100text-energy-800px-0.5 rounded-sm font-medium +
+
+
+
+
+ +
+

Pathway card

+

+ 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. +

+
+
+

+ Geographies: + +

+
+ Global +
+
+
+

+ Sectors: + +

+
+ PowerTransportIndustry+2 more +
+
+
+

+ Benchmark Metrics: + +

+
+ Emissions Intensity +
+
+
+
+
+

Publication:

+

World Energy Outlook 2024

+
+
+

Published:

+

2024

+
+
+
+
+ View Details +
+
+ +
+
+
+
+
+
+
+

Pathway card

+

PathwayCard.tsx

+
+ rounded-lg border-neutral-200shadow-md → hover:shadow-lgstrip: px-5 py-3 text-sm uppercasebody: p-5title: text-xl font-semiboldlabels: text-xs font-mediumbuttons: h-12 gap-2 +
+

+ 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. +

+
+
+
+
+
+
Transition Pathways Repository
+
+ Created by +
+
+ +
+
+
+

Header

+

+ Header.tsx · HeaderNav.tsx · ResourcesDropdown.tsx +

+
+ bg-bluespruce text-white shadow-mdpx-4 py-4title: text-xl md:text-2xl font-bold tracking-tight"Created by": text-xs md:text-sm text-white/80nav: px-3 py-2 text-sm font-semibold text-white/90 +
+

+ Shown at desktop size. On narrow screens the nav stacks under + the title. +

+
+
+
+
+
+
+
+ Transition Pathways + Repository +
+ +
+
© 2026 RMI. All rights reserved.
+
+
+
+

Footer

+

Footer.tsx

+
+ bg-bluespruce border-t border-basaltpx-4 py-8text-sm text-white hover:text-energycopyright: text-xs, border-t border-basalt mt-4 pt-4 +
+
+
+
+
+ +
+

Tabs v2

+

+ 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. +

+
+
+
+ + + + +
+
+
+

Tab list — selected / resting / keyboard focus

+

Tabs.tsx — Tabs + TabPanel + useActiveTab

+
+ px-4 py-2.5 text-sm font-mediumborder-b-2selected: border-bluespruce text-bluespruceresting: text-rmigray-600hover:text-bluespruce hover:border-neutral-300focus-visible:ring-energy +
+
+
+
+ + +
+

Scope ribbon v2

+

+ 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 +
+
+
+ Sector +
+
+ + + + +2 more +
+
+ Geography +
+
+ + + + +12 more +
+
+
+ +
+
+
+
+

Detail page ribbon — scrolled state

+

PathwayContextRibbon.tsx · ScopeAxisRow.tsx

+
+ sticky top-0 z-20bg-white/95 backdrop-blurborder-b border-neutral-200condensed: h-10 bg-bluesprucegrid-cols-[auto_1fr] +
+

+ 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. +

+
+
+
+
+

Comparison scope header

+

+ ComparisonScopeHeader.tsx · GeographyDivergenceNotice.tsx +

+
+ condensed: h-8 bg-bluespruce text-xs font-semiboldgrid-template-columns: repeat(n, 1fr)gap-x-6 +
+

+ 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. +
+
+
+
+

Column geography select — open

+

ColumnGeographySelect.tsx

+
+ text-xs rounded-md border-gray-300selected: bg-neutral-100max-h-64 overflow-autoshadow-lg +
+

+ 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. +

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricSector segmentGranularityScope limitationsGeography coverageTime resolutionData format
Capacity +
+ Power generation +
+
By technologyUtility-scale only +
+ GlobalSoutheast AsiaAfrica+18 more +
+
5-yearExcel annex
Storage +
+ Energy storage +
+
UnspecifiedUnspecifiedUnspecifiedAnnualUnspecified
+
+
+
+

Data availability table

+

+ DataAvailabilityTable.tsx (Scope & Granularity tab) +

+
+ thead: bg-bluespruce text-white font-semiboldpx-3 py-2 text-smeven rows: bg-neutral-50row header: font-medium text-rmigray-800cells: text-rmigray-700rounded-lg border-neutral-200 +
+

+ 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. +

+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DependencyEvidence typeConstraint
Grid expansion + Quantitative + Transmission build-out must triple by 2035.
Critical minerals + Qualitative + + Assumes supply chains scale without price shocks. +
Public acceptance + Anecdotal + Siting of new capacity proceeds on schedule.
CCS availability + No evidence + Commercial-scale capture available from 2030.
+
+
+
+

Dependencies table + evidence pills

+

+ DependenciesTable.tsx (Overview tab) — EvidenceBadge, + strongest → weakest +

+
+ Quantitative: pinishgreen-200 / 800 / 800Qualitative: rmiblue-100 / 800 / 200Anecdotal: solar-100 / 800 / 200No evidence: neutral-100 / rmigray-600 / neutral-300 +
+

+ 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. +

+
+
+

Policy types

+
+ Carbon priceFeed-in tariffsPerformance standardsPhaseout datesSubsidiesTarget technology sharesOtherNone +
+
+
+
+

Emissions

+
+

Emissions target (driver)

+

+ Not a core driver for this pathway. +

+
+
+

Emissions scope

+
+ No informationCO2CO2e (Kyoto)CO2e (CO2, Methane)CO2e (unspecified GHGs)Other emissions scope +
+
+
+
+

Technology

+
+

Technology cost drivers

+

+ Learning rates for solar, wind, and batteries follow + historical trends. +

+
+
+
+
+
+
+

Panel with group cards — Key Features / core drivers

+

+ KeyFeatures.tsx · CoreDrivers.tsx · ComparisonKeyFeatures.tsx +

+
+ panel: bg-neutral-50 border-neutral-200 rounded-lg + p-4title: text-lg font-medium text-rmigray-800card: bg-white border rounded-lg p-4card title: text-xs font-semibold uppercase tracking-wide + text-rmigray-500grid gap-4 md:grid-cols-2 lg:grid-cols-3chosen value: bg-rmiblue-100 text-rmiblue-800 + border-rmiblue-200other options: bg-neutral-50 text-neutral-400 + border-neutral-200 +
+

+ 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. +
+
+ Global +
+
+
+
+
+
+

Plot small multiples

+

PlotGrid.tsx · PlotPanel.tsx

+
+ figure: bg-white border rounded-lg p-4figcaption: text-xs font-semibold uppercase + tracking-widegrid-cols-1 lg:grid-cols-2 gap-4empty: h-32 text-xs italic text-rmigray-400 +
+

+ 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. +

+
+
+

Geography divergence notice

+

+ GeographyDivergenceNotice.tsx (comparison page) +

+
+ mt-2 text-xs text-rmigray-700 +
+
+
+
+
+
+

+ No dependencies have been recorded for this pathway yet. +

+
+
+
+

Empty state — nothing recorded

+

+ DependenciesTable.tsx · DataAvailabilityTable.tsx +

+
+ rounded-lg border-neutral-200 bg-neutral-50p-6 text-sm text-rmigray-600 +
+
+
+
+
+
+

No dependencies are recorded for Transport.

+

+ 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

+
    +
  1. + Install the Tokens Studio plugin (free) in Figma. +
  2. +
  3. + 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. +
  4. +
  5. + 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. +
  6. +
+
{
+  "color": {
+    "bluespruce": { "value": "#003b63", "type": "color" },
+    "energy": { "value": "#45cfcc", "type": "color" },
+    "energy-700": { "value": "#2ab5b2", "type": "color" },
+    "rmigray-800": { "value": "#231f20", "type": "color" }
+  },
+  "fontSize": {
+    "sm": { "value": "14px", "type": "fontSizes" },
+    "base": { "value": "16px", "type": "fontSizes" }
+  },
+  "borderRadius": {
+    "md": { "value": "6px", "type": "borderRadius" },
+    "full": { "value": "9999px", "type": "borderRadius" }
+  }
+}
+

+ 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 +

+
    +
  1. + Install the html.to.design plugin in Figma. +
  2. +
  3. + Download this file (tpr-ui-kit.html) from the + repository, then drop it into the plugin's + File tab. +
  4. +
  5. + 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. +
  6. +
+ +
+ 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); + } +}