Create a templated Figma chart from a grapher chart

SkillDatabases & data

Turn an OWID chart — a grapher slug, a customized grapher link, an MDim view, an explorer view, an admin link, a narrative chart, a bespoke (client-rendered React) visualization, or just a description — into a templated chart in the design team's yearly "Charts (YYYY)" Figma file. Exports the chart SVG, creates a new page named "YYYYMMDD Title (Creator)", places the original chart and an adapted template side by side, replicates title/subtitle/data source/note in the template's styles, fits the chart into the template, proposes better labeling (direct line/bar labels instead of legends) and annotations with the file's curvy arrows, and names the final frame with the kebab-case slug used for the website PNG. Also builds the 302-wide "small" and "pull" chart thumbnails that sit in an article's chart-rows and pull-chart blocks, including inside a guided chart. Trigger when the user asks to "create a figma chart", "make a static chart in Figma", "prepare this chart for Instagram / as a data insight image", "put this grapher chart into the Charts file", "make a small chart / pull chart / chart thumbnail", "make a static chart from this bespoke viz", or pastes a grapher/admin/narrative-chart link asking for a designed static version.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Create a templated Figma chart from a grapher chart skill

What this skill tells your AI

The instructions your AI receives, as published by owid/etl in .claude/skills/create-figma-chart/SKILL.md and read by ahel’s review.

This skill takes any OWID grapher chart and produces a designed static version in the design team's yearly Charts (YYYY) Figma file, following the team's DI Charts Guidelines and the Good Data Viz Checklist.

The defining principle: the template is law. You adapt the chart's content into the template — you never restyle what the template provides (fonts, colors, spacing, logo, footer layout). Anything you add on top (annotations, direct labels, arrows) uses the file's shared text styles and the Chart colors library, nothing else.

Model check, before anything else: the session context names the running model. On Fable, stop before the first Figma call and recommend re-running on Opus (or Sonnet for a mechanical re-export or a single text fix); continue only on the user's say-so — this skill is long chains of design judgment, and a build on the wrong model wastes the shared file's review cycle.

If the Figma tools arrive deferred, load them in one ToolSearch before the first Figma call. Discovering them one at a time costs a model turn each — ~7 over a run. Whether they arrive deferred is a harness setting, not an environment — a local session gets them that way too — so read your own session's tool list rather than inferring it, and take the prefix from there: both mcp__Figma__ and mcp__claude_ai_Figma__ are in use, and naming the wrong one matches nothing.

select:<prefix>use_figma,<prefix>get_screenshot,<prefix>get_metadata,<prefix>upload_assets,<prefix>search_design_system

Add get_design_context or download_assets when the route needs them. Skip this entirely where the tools are already loaded — an unnecessary ToolSearch is itself a wasted turn.

In a cloud session the authenticated admin.owid.io routes are unreachable — Cloudflare Access 302s them to a login page. That is an app-layer redirect, not the egress gateway, so recentRelayFailures stays empty while the call quietly redirects; don't go hunting for credentials. Measured from a sandbox: /api/narrative-chart-map works (it is unauthenticated), while /api/figma/image and POST /api/images are blocked — so Step 9's PNG export and upload move to the user's machine. That export is optional for a full-size chart, but for a 302-wide small or pull chart the PNG is the deliverable — a cloud session can build the frame and not ship it, so say which at delivery. cloud-sandbox.md has the read-only fallbacks for chart config.

A cloud session is not the slow one. get_screenshot measured about twice as fast there (7.8–9.9 s against 12.5–20.5 s locally), use_figma six times (0.70 s against 4.4 s), and the model turn is no worse — 2.8 s cloud against 3.7 s local on identical probes. What makes any run slow is turns × (turn + call), so the Round-trip budget below is where a run is won or lost, and batching is what wins it.

The single checkpoint rule: the Charts file is a shared design file other people work in. Nothing is written to it before the user has seen the full proposal (page name, template choice, texts, planned label/annotation edits) and explicitly approved. Reading the file to check conventions needs no permission.

Say it in plain words. Most people who ask for a chart are not data scientists, so in chat name what changed and why it matters — not the mechanism. Keep pixel measurements, node ids, style keys, check-row names and API calls for the final report, where someone can go looking for them. Lead with the verdict ("it passed", or "I found two problems"), then the detail. GUIDELINES.md → Talking to the person you're building for has the before/after.

Read GUIDELINES.md (sibling file) before editing any chart — it distills the DI Charts Guidelines per chart type and the Good Data Viz Checklist.

Paired skill — an update here may oblige an update there, and the reverse. /create-static-viz writes the viz://static matplotlib step whose SVG this skill picks up, so the two share a contract that lives half in each file. When you change something on this list, check the other skill in the same session and update it too — or state explicitly that you checked and no change was needed. Neither side is allowed to drift silently; a stale cross-skill fact is how a run re-derives geometry by trial and error.

Shared factOwnerConsumed by
Template geometry — node ids, sizes, band top, footer startsTEMPLATES.mdboth
The content box and the band a chart is fitted intoTEMPLATES.md, re-verified here each runboth
Node naming (gids) the step emits, and frame proportions/create-static-vizthis skill's Steps 1/3/7–8
Which text slots the step fills vs. leaves to the template/create-static-vizthis skill's Step 6
Type and palette — the step sets neither, this page owns boththis skill/create-static-viz defers to it
The design vocabulary (per chart type, labeling, colors)GUIDELINES.mdboth
How Figma MCP calls are batched, and what is serialthis skill (Round-trip budget)both

The asymmetry worth remembering: that skill owns the data, the geometry and the proportions; this one owns the type and the palette. A change that crosses that line belongs in both files.

Two more sibling files own a route each, and both replace rather than supplement the steps below:

FileWhen
SMALL-CHARTS.mdthe output is a 302-wide small or pull chart — an article thumbnail for a chart-rows or pull-chart block. Different templates, a free frame height, its own export mode, no fit, an 11px floor, a PNG-to-Cloudflare delivery.
BESPOKE-SVG.mdthe input is a bespoke visualization — a client-rendered React viz with no .svg endpoint. Covers getting a chart-only SVG out of one; after that this page applies unchanged.

This page is the spine: the step order, the checkpoints, and the routing. The detail for each step lives in reference/ and is read at that step, not up front — the whole set is ~510 KB and no run needs all of it.

ReadWhenCovers
reference/RESOLVING-THE-CHART.mdStep 1Every input form's route to an SVG URL and the trap in each, where each template text comes from, and the entity-completeness check with the right baseline.
reference/EXPORTING.mdStep 3, before the embed exportThe canvas-versus-chart aspect solve and its per-axis insets, imFontSize, the imType=square alternative and when it wins, and the silent tab= fallback.
reference/NODE-MAP.mdStep 5, before cloning anythingThe yearly Charts file's node ids, the ten templates, per-family slot sizes, header sizing and the band table. Run scripts/verify_templates.js from here every run — a DRIFT verdict stops the run.
reference/TEXTS.mdStep 6Filling the template's text slots, and the header reflow that makes the band measurable.
reference/FITTING.mdStep 7Measuring the band, importing the embed, unwrapping and scaling. The local-SVG restyle route.
reference/LABELING.mdStep 8, 8b, and any re-exportDirect labels, highlighting, the palette and its bound styles, annotations and arrows. What to replay after a re-import — scripts/replay_chart_edits.js does it in one call, in the right order.
reference/CHECKS.mdStep 8c, before showing anyoneThe gate. Every check, and the rule to re-run the pass after the last change. scripts/verify_page.js runs the mechanical rows as three sliced calls (it never fits in one) and declares what it cannot judge; scripts/diff_against_template.js checks the finished frame back against the template it was cloned from.
reference/GOTCHAS.mdOn an error, or grep by symptomEvery known pitfall. Worth one skim before your first use_figma call.

GUIDELINES.md stays eagerly read — it is pointed into from all over this page — but its per-chart-type conventions are now one file each under reference/per-chart-type/. Read only the one for the chart in hand.

Size budget, enforced by --structure: spine under 64 KB, GUIDELINES.md under 80 KB, the pair under 145 KB. (Raised from 62/140 on 2026-08-28, once, to land the benchmark's second-run lessons — the spine was already 925 bytes over before they were added, so the old figure had stopped describing the file. A cap that moves whenever it is hit is not a cap: the discipline below is unchanged, and the next addition pays for itself by deduplicating rather than by another raise.) Held at 64 KB on 2026-09-02, with 19 bytes left, by moving Steps 1 and 3 into reference/ — the remedy this paragraph names, and the one Steps 6 to 8c already model: the spine routes, the step's own file carries the detail. Both are read on every run, so a paragraph added here costs every future chart — and moving one from this file into GUIDELINES.md saves a run nothing, which is why the pair is capped and not just each file. New detail belongs in the reference file for its step, which is read only at that step. After editing any doc in this skill:

.venv/bin/python .claude/skills/create-figma-chart/scripts/verify_docs.py --structure
# and after moving text between files, prove nothing was dropped:
.venv/bin/python .claude/skills/create-figma-chart/scripts/verify_docs.py --against <ref-before-the-move>

--against normalizes away heading levels, link depth and pointer rewrites, so anything it reports as LOST is a real instruction that went missing. REWORDED findings are for you to accept or reject.

Three sibling skills do the text work this one depends on, and Step 8c calls them: /adversarial-data-review (is the FAUST true of the indicator, and is the data), /check-metadata-style (the Writing and Style Guide) and /check-metadata-typos (codespell). Anything they turn up is an upstream fix in the garden step, not a Figma edit.

Round-trip budget

A call costs twice: the call, and the turn around it. The call is a network hop to Figma's hosted connector, and the cloud is the fast sideget_screenshot 8–9 s there (60 calls, median 8.8 s, range 7.7–10.6 s) against 12.5–20.5 s locally; use_figma 0.70 s there against 3.5–5.8 s locally (31 calls, three sessions). A use_figma is much the cheaper of the two, not equal to a screenshot. Either is flat regardless of script size or render size — a 545-char script that loaded a page and walked 286 nodes came back faster than a 45-char one, and a natural-size screenshot costs 1.10× a 256 px one. So collapse work into fewer calls freely, and never shrink a screenshot for speed. The turn tracks the work in it, not the environment — identical light probes measured 2.8 s in the cloud and 3.7 s locally, so there is no cloud turn penalty. The ~12 s this file once billed to the cloud came from 23 turns doing real chart work: read it as the heavy-turn figure, which is what a real run pays in either environment. Budget a run as turns × (turn + call), the call off the environment and the turn off the work. An unbatched heavy-turn screenshot costs ~20 s, so 120–190 calls come to ~50 minutes serial. Nothing else is close: not the SVG exports (0.05–0.3 s locally, 0.9–2.6 s through a sandbox's egress proxy, under 2 s for a whole run locally) and not the response payloads (~1.5 KB per use_figma).

What a build should cost, to calibrate against mid-run: ~14 Figma calls per template — measured at 18 end to end, 4 of them on bugs since fixed — against ~21 per template a run earlier, and 124–188 for a whole chart before any of this. FITTING.md credits the saving.

So the unit to minimize is messages, not calls. A batch collapses both costs at once — the connector serves the calls concurrently and they share one turn. Yet across five measured sessions two batched nothing at all and two barely did (1–9% of calls overlapping); one batched heavily and paid for it (see the ceiling below).

Fan out independent calls — one message, 4–6 at a time. The payoff is ≈4.0× and environment-neutral — ten reps of a fixed six-call probe a side, 4.00× cloud against 3.84× local, six in flight every time; only what each call costs differs. An eighth call still helps (an earlier eight-screenshot run measured 4.1×, 79.7 s of work in 19.4 s), but the connector admits about four or five at once and latency inflates past that, so 4–6 per message is the sweet spot and more just queues. This is the figma-use skill's own instruction too: issue the N calls in one message, and don't await one before issuing the next.

A batch's wall clock is first call + rate × (n−1), and both terms are environment-specific: 9.2 s + 0.75 s per extra call in a cloud session, 11.7 s + 2.1 s locally (ten reps a side; predicts 12.9 s and 22.2 s against measured 13.2 s and 22.3 s). That is the stopping rule, and it says the environments batch differently, not just slower: in the cloud the completions finish in a narrower spread than they were dispatched in — near-true parallelism — while locally they pipeline, each call ending ~2.1 s after the last. All of this is get_screenshot; batching use_figma buys only the shared turn — plugin runs serialize per file, 0.8–1.1× on the calls (GOTCHAS). sum/wall is not the honest gain either — it counts a queued call's own wait as work, so locally it flatters batching (2.8–3.2× against a serial baseline, not 3.84×) and in the cloud understates it (4.19× against 4.00×).

Reads fan out freely — including reads that each switch pages, which makes the Step 5 and Step 8c rows below safe: two concurrent calls, one holding Cover and one the Templates page, overlapping for 7.7 s, each saw only its own — figma.currentPage is per-call. It is concurrent mutation of one page that races, not the switch. Writes only when they target different pages — a script may switch pages only once, so two use_figma writes aimed at the same page in one message race each other.

What is independent — the batch manifest, keyed by the step that owes it. Issue each row's calls in one message, so batching is mechanical rather than a fresh judgment call every run.

  • Step 5 — the page survey. The page enumeration and verify_templates.js go together. Checking N pages means N calls — page.children on a page you have not switched to is lazily loaded — and they fan out.
  • Step 8c — the checks. verify_page.js is three read-only slices and diff_against_template.js is one; issue all four together, with every pixel probe that reads one fixed state.
  • Step 9 — the delivery renders. One screenshot per delivered frame, all in one message.
  • The palette harvest. search_design_system caps at ~14 results against a 24-fill palette, so it takes one group query plus ~11 by-name queries. All independent; 4–6 per message.
  • A size-only survey is one batch, not two. get_screenshot returns original_width/original_height — the node's natural size — beside the rendered dimensions, so it already answers how big a frame is. Add get_metadata only when you also need names or structure; eight A/B runs each paid for both batches before noticing.
  • Screenshots of different frames or pages. Issue them together, then download all the URLs in one parallel bash call (pattern and its two silent traps: Gotchas). Keep these on the hosted get_screenshot: most frames a build screenshots are ones it just wrote, and the desktop reader cannot see those (Gotchas).
  • Every format in a multi-format run, and every frame of a chart-rows set — their reads fan out. Their writes do not: one chart is one page, and Step 4 lines the formats up on that page, so those frames are separate frames on a shared page — which by the rule above makes two use_figma writes in one message race. Batch the screenshots and property reads; build the frames one call at a time.
  • Any survey of N nodes — but at 4–6, not more; past that the connector queues and every call slows (Gotchas).
  • upload_assets takes a count. One call returns N single-use submitUrls and the POSTs parallelize, so a two-format run uploads both originals in one call rather than two — and both embeds in one more.
  • The Step 8c property sweeps — font sizes, stroke weights, dash patterns, fills, polylines. Those are reads of a single page, so they collapse into one use_figma returning one JSON. scripts/verify_templates.js already does exactly this for ten templates.
  • The arrow probe's baseline render. Only the FULL render is shared across arrows: the other three states of the four-render protocol (no-arrow, no-target, both-hidden) each hide that pair's nodes, so they are pair-specific and cannot be reused. N arrows cost 3N + 1 screenshots, not 4N — and not N + 2, which under-collects and produces masks containing another pair's target.

What is serial for a reason — don't collapse these:

SequenceWhy
trim → position → read heightleadingTrim does not update height within the call that sets it (Step 7)
original → clone → fill texts → measure band → export embed → fitthe band is not knowable until the real title and subtitle have reflowed the header (Step 3)
one page per use_figma callpage.children on a page you have not switched to returns a short list without erroring (Gotchas)
hide → render → hide → renderthe four-render arrow probe needs a different visible state per render, so batching them races the writes and the masks capture the wrong state (CHECKS.md)

And a bigger batch is a bigger loss: use_figma is atomic, so a script that throws on its last line reverts the whole pass. Stay inside the plugin's ~10-logical-operations-per-call guidance.

To check whether a run actually batched, sweep tool_usetool_result intervals for peak in-flight calls — never a calls-per-message count, which reports singletons for a provably concurrent run (Gotchas).

Inputs

  • A chart reference, in any of the forms of the Step 1 table. If the user only describes the chart ("the life expectancy chart with just the US and China"), resolve candidates first and confirm.
  • Or a local SVG already on disketl/steps/viz/static/…/<name>.svg from an viz://static step, handed over by /create-static-viz. Its texts are baked in and its frame already matches a template, so Step 1's text sourcing and Step 3's export both fall away; follow the local-SVG notes in Steps 1, 3, 5 and 7.
  • Or a bespoke visualization — a client-rendered React viz from owid-grapher's bespoke/projects/*, with no .svg endpoint at all. BESPOKE-SVG.md covers getting a chart-only SVG out of one; after that it behaves like grapher's uncaptioned embed and every step here applies.
  • Optionally, the DI/article text the chart accompanies — the best source for annotation content. Ask for it if annotations are wanted and it exists.
  • Optionally, a link to a finished page in the file to work like (see below).
  • Everything else (formats, credit, slug, topic link) is collected once in Step 2.

One output format has its own file. A small or pull chart — the 302-wide thumbnail that sits in an article's chart-rows or pull-chart block — diverges from everything below at almost every step: a different pair of templates, a free frame height, its own export mode (imType=thumbnail), no fit, an 11px text floor and a PNG-to-Cloudflare delivery. Read SMALL-CHARTS.md instead of improvising from this page, and take only the shared conventions (GUIDELINES.md, the Step 8c checks it doesn't override) from here.

When you're pointed at a finished page as the model

Read reference/REFERENCE-PAGE.md for this mode. A designer's own page is a better spec than this file, and reading it answers most of Step 2 in measurements. That file covers which template and export route the page used, what was done by hand, the trap of reporting their numbers as your own, reading small details off a rendered crop rather than the vector geometry, re-rendering the chart yourself before assuming it reproduces, treating the page as stale (on a five-page run every one of them was), and how to read a designer's rework — or a loose TEXT node dropped beside your frame — as the feedback it is.

Step 1 — Resolve the chart and gather its text

Get an SVG URL for the chart, then pull its texts. reference/RESOLVING-THE-CHART.md carries the full routing table and the trap in each route — read the row for the input you were handed, not the whole table.

InputSVG URL
Slug, default grapher link, or MDim viewhttps://ourworldindata.org/grapher/<slug>.svg?<the view's params, kept verbatim>
Explorer viewhttps://ourworldindata.org/explorers/<slug>.svg?<the view's FULL param set> — requested bare it returns an axis and nothing else, at HTTP 200
Admin link /admin/charts/<id>/editresolve the chart's configId, then grapher/by-uuid/<configId>.svg/admin/charts/<id>.svg does not exist
Narrative chartname → uuid via admin.owid.io/api/narrative-chart-map, then grapher/by-uuid/<uuid>.svg. A numeric id needs a Datasette lookup, and an id newer than the mirror needs the guessing route
Bespoke componentno endpoint — render and serialize it yourself (BESPOKE-SVG.md)
Description onlyfind candidates by search, and confirm one before proceeding
Local SVG from an viz://static stepnothing to resolve and no texts to pull — the step baked them into the file

Then pull the chart's texts, which seed the template texts in Step 6. Read .metadata.json, not .config.json, and keep the view's query params on the request: it gives title, subtitle, note and chart.citation, with grapher's detail-on-demand markup already unwrapped. The by-uuid route is the exception — it has no .metadata.json, so a narrative chart's texts come off .config.json plus the rendered SVG's footer.

Two things here are not optional, and the reference file works both through:

  • Never shorten the producer's name to make it fit. When it overruns the CC BY text, wrap it (Step 7) rather than editing it.
  • Check that every selected entity actually renders, comparing the SVG against the effective selection rather than the saved one — and never against the rendered labels themselves. Grapher drops an entity with no data in the displayed window silently, so a check that reads the SVG as its own baseline reports success precisely when it should fail.

Step 2 — Ask the run options, all at once

One AskUserQuestion batch — don't drip-feed or assume an answer while asking (Gotchas):

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
156
Forks
30
Last commit
Sep 2026

ahel review

  • S4info
    community integration — published by owid, not figma

Automated review, not a security audit. Ruleset v1.

Advanced
Catalog kind
skill
Gateway key
create-figma-chart
Source
github.com/owid/etl