| name | create-figma-chart |
| description | 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. |
| metadata | {"internal":true} |
Create a templated Figma chart from a grapher chart
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 export://static_viz 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 fact | Owner | Consumed by |
|---|
| Template geometry — node ids, sizes, band top, footer starts | TEMPLATES.md | both |
| 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-viz | this skill's Steps 1/3/7–8 |
| Which text slots the step fills vs. leaves to the template | /create-static-viz | this skill's Step 6 |
| Type and palette — the step sets neither, this page owns both | this skill | /create-static-viz defers to it |
| The 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 . |
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.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 returning one JSON. already does exactly this for ten templates.
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/export/static_viz/…/<name>.svg from an export://static_viz 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.
| 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 export://static_viz 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):
- Output format(s) (multi-select — several deliverables from one run are normal). Constraint from the design team: Instagram and DI images are always square/mobile; a static chart (for the OWID website) can be desktop and/or mobile:
- Instagram post (square 540×540) or portrait (560×700)
- Data insight image (DI_Template, 540×540)
- Static chart — mobile/square (540×540 or 540×824) and/or desktop (Horizontal 850×638 / Vertical 850×1095; Vertical when the chart needs height — rankings, long bar lists)
- Guided /
chart-rows thumbnail (302 wide, free height, no source row) or pull chart (302 wide, free height, mandatory source) — both go to SMALL-CHARTS.md, which owns the rest of the run. The answer picks the template, so ask which block the image is for rather than inferring it from the size.
- Who is building the chart — this is the page-name credit, and it is not the author of the DI or article the chart accompanies. Default to the user; don't infer it from the gdoc, which names the writer rather than whoever does the design work.
- First names only, matching the file's existing pages:
(Charlie), (Hannah), (Bertha).
- Disambiguate a shared first name with the last initial —
(Pablo A) for Pablo Arriagada, (Pablo R) for Pablo Rosado. Both are in use, so a bare (Pablo) is ambiguous.
- Several people, comma-separated:
(Bastian, Charlie).
- An organization instead of a person when there is no individual:
(Our World in Data - Global Change Data Lab).
- The author of the piece, separately, and only when a static template — desktop or mobile — is among the formats. Those carry a
Licensed under CC-BY by the author <Name> line, and that name is the writer being credited for the work, which is often not the person building the chart. Mobile gained this line on 2026-08-13 along with its second footer row, so a mobile-only run needs the question too; skip it for DI and Instagram, whose footers say only CC BY, and for the 302-wide formats, which have no footer at all.
- The DI's own title — or the claim the image is meant to make. Ask for this whenever a DI or Instagram image is among the formats, and ask : grapher's descriptive title must not survive into those images (GUIDELINES.md → Titles), and the story is not yours to invent. If there's no title written yet, ask for the sentence the image supports and derive a candidate from it for approval in Step 4.
Step 3 — Export the SVGs
Two exports per format family: the original (placed on the page as the reference copy) and the embed (chart area only, placed inside the template). reference/EXPORTING.md carries the aspect solve, the imFontSize rule, the square-render route and the tab= trap — read it before you export the embed.
Export the original now; export the embed only after the template texts are in (Step 6). The embed's aspect ratio has to match the band between the header and the footer, and the header's height is not known until the real title and subtitle have reflowed it — a three-line title moves the band by ~30px, which is twice the whole gap budget. The order that avoids re-exports is: original → clone the template → fill the texts → measure the band → export the embed → import and fit. Every page in this run that skipped ahead needed a second export.
DIR=/tmp/figma-chart && mkdir -p $DIR # or the session scratchpad
# Original — desktop templates (850×600 default render)
curl -sL "https://ourworldindata.org/grapher/<slug>.svg?<params>&nocache" -o $DIR/original.svg
# Original — square/mobile templates (grapher's own square re-layout, 540×540)
curl -sL "https://ourworldindata.org/grapher/<slug>.svg?<params>&imType=square&nocache" -o $DIR/original_square.svg
# Embed — chart area only, no grapher header/footer, at the template chart-area aspect ratio
curl -sL "https://ourworldindata.org/grapher/<slug>.svg?<params>&imType=uncaptioned&imWidth=<W>&imHeight=<H>&nocache" -o $DIR/embed.svg
scripts/solve_export.py solves the embed's numbers — don't do the arithmetic by hand. --band 508x371 --slug <slug> --params '<the view's query string>' returns the imFontSize, the imWidth/imHeight to request, the height-first scale into the band and the finished curl. Omit --params and that curl exports the DEFAULT chart — a valid, plausible SVG of the wrong entities. It is a two-pass tool, and pass 1 is a probe even when your numbers are measured; EXPORTING.md says why, and what changes between the passes.
Local SVG on disk: skip this step entirely. A file from an export://static_viz step already is the framed chart at a template's proportions, so none of the aspect solving applies — take the SVG and the PNG the step emitted, and follow the local-SVG route in Steps 5 and 7.
Step 4 — Propose, then get the go-ahead
If the title changes later, rename the page too. The page name carries the final title, and a
title that gets corrected mid-run — because it misread the data, or because it wrapped to a line too
many — leaves the page still asserting the superseded claim. It is the one place the old wording
survives a retitle, since nothing renders it.
Before touching the file, show the user in one message: the page name YYYYMMDD <Title> (<Creator>) (today's date, the final — possibly rewritten — title), the chosen template(s), every text that will go into the template, the labeling changes you propose (Step 8), and the annotations with their content. Wait for explicit approval. This is the single checkpoint; after it, iterate freely on the same page without re-asking.
Two things to ask about, not decide
Both change what the reader sees, both are cheap to do and awkward to undo, and neither has a right
answer you can work out from the data. Ask them with the numbers from the chart in hand, and ask
in plain words — no "antimeridian straddler", no "content width", no pixel arithmetic. The person
answering needs to know what changes and what it costs, nothing else.
1. Small islands on a map. A world map often sits small in its space because a few tiny islands
reach out to the far left and right edges. Leaving them out lets the map fill the width.
"The map is sitting small because a few tiny Pacific islands stretch out to the edges. I can leave
them out — Hawaii, Fiji, Kiribati, Samoa, Tonga, Tuvalu, Nauru and the Marshall Islands — which makes
the map about 14% bigger. Those places would no longer be drawn; most are under 2px across here, so
they are barely visible either way. Leave them out, or keep everything and accept the smaller map?"
Details, and what it measured last time, in reference/per-chart-type/maps.md.
2. Long country names. This applies to any chart that labels entities, not just bar charts —
what differs is where the space goes. On a bar chart the names sit in a column on the left that is as
wide as the longest name, so shortening anything but the longest one gains nothing. On a slope or
line chart the labels eat into the plot at the end they sit on. On a scatter or a marimekko there is no
column at all and the gain is fewer collisions, so a shorter name can be worth it even when no edge
moves. Work out what the labels are actually costing before you ask
(reference/LABELING.md has the per-type version).
US and UK are settled: the Writing and Style Guide rules on those two, without periods
(STYLE_GUIDE.md). For any other name you may propose a
short form, provided it is one a reader would already recognise — and propose it, never apply it. The
line to hold is between a common abbreviation and an invented one: a reader cannot tell which is
which, so anything they would not recognise reads as data rather than as our layout choice. If you are
not confident it is in common use, say so in the question and let the user decide.
Measure first, then ask. When the two settled ones are enough:
"The names on the left take up a lot of room, and the widest one decides how much. I can shorten
'United States' to 'US' and 'United Kingdom' to 'UK', which makes the bars about 5% longer. Shorten
them, or keep the full names?"
When the widest name is a third country, name the short form you have in mind and own that it is a
suggestion:
"The widest name is what sets that column, so shortening 'United States' to 'US' alone would not
make the bars any longer. If we also shorten the widest one — I'd suggest , but tell me if
that is not how we write it — the bars get about 5% longer. Shorten them, or keep the full names?"
The measurements, and the four-step re-layout that keeps the bar lengths proportional to the data, are
in reference/per-chart-type/bar.md.
Step 5 — Create the page and place the pieces
Template ids, sizes and the band table are in reference/NODE-MAP.md — and scripts/verify_templates.js runs from there before you clone.
Load the figma-use skill before any use_figma call — hard prerequisite. It covers loadFontAsync before text edits and the other plugin-API rules.
- Enumerate pages with
use_figma, not get_metadata — the MCP page listing is unreliable (it returns only "Cover" for this file). Dated chart pages sort newest-first after the instructions/templates pages; insert the new page at the top of the dated block, matching the existing order. The dated block starts below a divider page named ----------------------------------------- — find it (/^-{10,}$/ on the trimmed name) and insert after it rather than at a counted index, or the new pages land among the instruction pages. Watch the off-by-one when you move several: insertChild(i, p) removes before it inserts, so re-inserting pages that are already at those indices is a no-op — the reliable fix is to place the pages and then move the divider to its own index once.
const pages = figma.root.children.map((p, i) => `${i}: ${p.name}`)
const page = figma.createPage()
page.name = "20260810 Child mortality in Asia (Pablo A)" // creator, first name + last initial if ambiguous
// move it to the right index with figma.root.insertChild(index, page)
await figma.setCurrentPageAsync(page)
-
Clone the template frame(s) onto the new page: (await figma.getNodeByIdAsync("<template-id>")).clone(), then page.appendChild(clone) and position it.
For a 302-wide small or pull chart, clone 25344:1357 (guided) or 25344:1391 (pull) — the choice is the Step 2 answer, not a judgement. Both now carry a real visible white frame fill and no background vector, so there is nothing to repair before you start; check that still holds rather than assuming, since a hidden fill plus a fixed-size backing vector is exactly what a taller frame under-covers. A chart-rows block is 3–5 rows, so expect a set of frames on one page. SMALL-CHARTS.md → In Figma has the rest.
-
Import the original SVG with upload_assets — never createNodeFromSvg (the use_figma code param caps at 50k chars; a grapher SVG is ~165 KB). upload_assets takes a count and returns that many single-use submitUrls — pass count: 2 for a two-format run and POST both in parallel. Keep each returned placedOnNodeId. Only the original at this stage — the embed has not been exported yet (Step 3), and it arrives in Step 7 once the band is measurable:
curl -s -X POST "<submitUrl>" -F "file=@$DIR/original.svg;type=image/svg+xml"
# → {"success":true, ..., "placedOnNodeId":"<id>"}
For two or more assets, run the POSTs concurrently rather than one per call — Gotchas has the pattern.
- Lay out the page: the original chart on the left; the adapted template to its right (~100 px gap). If several formats were requested, keep one original and line the templates up to its right. Move imported nodes with
use_figma (page.appendChild(node), set node.x/node.y). This page-level parenting is for the original reference chart only — the embed gets reparented into the template clone in Step 7.
Imported SVGs arrive at their natural size (850×600 / 540×540). Scale with node.rescale(factor) — never resize(), see Step 7.
Bin the import frame — on every import. upload_assets wraps the SVG in a FRAME that OWID's charts don't have and that causes two failures of its own: it carries a white fill that paints over the footer as soon as the frame overhangs, and resize() on it stretches its children through their constraints — which silently rewraps every text box in the chart, because grapher's exported labels are sized to their glyphs with no slack ("Brazil" becomes "Bra zil"). Move the frame's children out to their real parent and delete the frame; a plain group is what the finished pages use. Only the destination differs: the original unwraps onto the page, here in Step 5; the embed unwraps into the template clone in Step 7, after its own export.
const unwrap = (imported, parent, name) => { // parent = the page (original) or templateClone (embed)
const kids = [...imported.children];
for (const k of kids) parent.appendChild(k);
imported.remove();
const node = kids.length === 1 ? kids[0] : figma.group(kids, parent);
node.name = name;
return node;
};
Local-SVG route. Two imports, and neither is an embed: place the step's PNG on the page as
the left-hand reference copy, and unwrap the SVG straight into the template clone here in Step 5
rather than waiting for a band measurement in Step 7. Waiting buys nothing — the SVG's frame already
carries the template's aspect, so there is no aspect to solve against the header and footer. It
does not arrive at the template's size, though: Step 7 still owes it one uniform rescale to the
clone's width. Keep the clone, too: the SVG's text is matplotlib's, not in the file's bound
Lato/serif styles, so Step 6 still fills the template's own slots and Step 7 then drops the SVG's
duplicate text nodes.
Step 6 — Fill the template texts
Read reference/TEXTS.md for this step.
Replace the cloned template's lorem-ipsum text nodes with the chart's title, subtitle, Note: and Data source:, in the template's own bound styles. Filling them reflows the header, which is what makes the band measurable in Step 7 — so this comes before the embed export.
Step 7 — Fit the chart into the template
Read reference/FITTING.md for this step.
Measure the band off the filled clone, export the embed to that aspect, import it, unwrap the frame, and scale it in. Covers the local-SVG restyle route and scripts/restyle_static_import.js.
Step 8 — Improve the labeling and annotate
Read reference/LABELING.md for this step.
Direct labels instead of legends, the highlight treatment, the palette and its bound styles, annotations and the file's curvy arrows — plus what to replay when a re-import wipes the chart-local edits.
Step 8c — The checks that must pass before you show it
Read reference/CHECKS.md for this step.
verify_page.js never fits one use_figma call — don't paste it, and never hand-roll a subset instead. Emit its three slices; each runs alone:
.venv/bin/python .claude/skills/create-figma-chart/scripts/inline_script.py verify_page.js --rows type,geometry --frame-id <frame> # then series,skipped; then annotations
The checks are a gate, not a formality: re-run the whole pass after the last change, not after each one, and treat "I already checked that" as false after any re-export, reorder, rescale or restyle.
Step 9 — Checklist pass, review, deliver
-
Run the Good Data Viz Checklist (GUIDELINES.md, final section) against the composed frame; fix what fails.
-
get_screenshot the new page and show the user — original and adapted version side by side. Iterate on feedback (no re-approval needed within the approved page).
-
Rename the final frame to the slug from Step 2 (child-mortality-asia-decline) — Figma uses the frame name as the export filename for the website PNG. Exactly one frame carries the bare slug; variants get a suffix (-palette-a). Two frames with the same name export two files with the same name.
When the user picks a variant, move the bare slug onto it in the same breath — never leave the rename as an open item. It reads like a one-line loose end and it is not: the page ends up with a single finished frame still called …-palette-a, and the PNG the website gets is named after a trial. Renaming is free while the choice is being made and invisible afterwards.
-
Clear the rejected variants off the page. Proposal frames accumulate fast — a palette trial, a labeling trial, a layout trial — and a page with four near-identical charts makes the reader work out which one is live. When the user picks, delete what they didn't pick and keep what they asked to keep; a variant kept deliberately is fine, one left behind by accident is not.
-
Do not export a PNG by default — the designer usually keeps editing. On request, let the user export from Figma, or use the admin's Figma endpoint below. get_screenshot cannot do it: maxDimension only ever downscales and clamps at the node's natural size, so a 540 frame returns 540px however large a number you pass, and a 302 frame returns 302px. There is no way to get the 3× (1620×1620) DI export through it.
For a 302-wide thumbnail the export is part of the deliverable rather than optional, and it has its own route: GET /api/figma/image?fileId=<key>&nodeId=<node> on the OWID admin (adminSiteServer/apiRoutes/figma.ts) calls the Figma API at scale: 3, then POST /api/images uploads it to Cloudflare Images. PNG only — ACCEPTED_IMG_TYPES rejects SVG. Its 3× disagrees with the family's 2×; SMALL-CHARTS.md → Delivery has the rules. Neither call reaches admin.owid.io from a cloud session, so there the export and upload move to the user's machine — say that when you deliver instead of leaving the deliverable half-finished.