Create a templated Figma chart from a grapher chart
SkillDatabases & dataTurn 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.
No other account needed.
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-vizwrites theviz://staticmatplotlib 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 fact Owner Consumed by Template geometry — node ids, sizes, band top, footer starts TEMPLATES.mdboth The content box and the band a chart is fitted into TEMPLATES.md, re-verified here each run both 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 both this skill /create-static-vizdefers to itThe design vocabulary (per chart type, labeling, colors) GUIDELINES.md both How Figma MCP calls are batched, and what is serial this 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:
| File | When |
|---|---|
| SMALL-CHARTS.md | the 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.md | the 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.
| Read | When | Covers |
|---|---|---|
| reference/RESOLVING-THE-CHART.md | Step 1 | Every 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.md | Step 3, before the embed export | The 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.md | Step 5, before cloning anything | The 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.md | Step 6 | Filling the template's text slots, and the header reflow that makes the band measurable. |
| reference/FITTING.md | Step 7 | Measuring the band, importing the embed, unwrapping and scaling. The local-SVG restyle route. |
| reference/LABELING.md | Step 8, 8b, and any re-export | Direct 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.md | Step 8c, before showing anyone | The 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.md | On an error, or grep by symptom | Every 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 side — get_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.jsgo together. Checking N pages means N calls —page.childrenon a page you have not switched to is lazily loaded — and they fan out. - Step 8c — the checks.
verify_page.jsis three read-only slices anddiff_against_template.jsis 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_systemcaps 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_screenshotreturnsoriginal_width/original_height— the node's natural size — beside the rendered dimensions, so it already answers how big a frame is. Addget_metadataonly 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-rowsset — 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 twouse_figmawrites 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_assetstakes acount. One call returns N single-usesubmitUrls 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_figmareturning one JSON.scripts/verify_templates.jsalready 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 + 1screenshots, not4N— and notN + 2, which under-collects and produces masks containing another pair's target.
What is serial for a reason — don't collapse these:
| Sequence | Why |
|---|---|
| trim → position → read height | leadingTrim does not update height within the call that sets it (Step 7) |
| original → clone → fill texts → measure band → export embed → fit | the band is not knowable until the real title and subtitle have reflowed the header (Step 3) |
one page per use_figma call | page.children on a page you have not switched to returns a short list without erroring (Gotchas) |
| hide → render → hide → render | the 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_use → tool_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 disk —
etl/steps/viz/static/…/<name>.svgfrom anviz://staticstep, 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'sbespoke/projects/*, with no.svgendpoint at all. BESPOKE-SVG.md covers getting a chart-only SVG out of one; after that it behaves like grapher'suncaptionedembed 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.
| Input | SVG URL |
|---|---|
| Slug, default grapher link, or MDim view | https://ourworldindata.org/grapher/<slug>.svg?<the view's params, kept verbatim> |
| Explorer view | https://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>/edit | resolve the chart's configId, then grapher/by-uuid/<configId>.svg — /admin/charts/<id>.svg does not exist |
| Narrative chart | name → 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 component | no endpoint — render and serialize it yourself (BESPOKE-SVG.md) |
| Description only | find candidates by search, and confirm one before proceeding |
Local SVG from an viz://static step | nothing 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