Tufte-style charts
skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-12
Clean, directly labelled charts from a JSON spec that read in light and dark mode: stacks, lines, slopegraphs, small multiples.
MountTufte-style charts
Quiet, directly labelled charts from a JSON spec, as SVG/HTML that follows light and dark mode, and as PNG or WebP.
Image: Preview
A style rulebook and four chart scripts: a stacked-area or stream chart, a multi-line chart, a
slopegraph and small multiples. Each script reads one JSON spec (the wording, plus the data inline
or in a file beside the spec) and writes an HTML page with an inline SVG. Every word on the chart
comes from the spec, with numbers filled from the data. render.mjs turns the page into PNGs, and
to_webp.py turns a PNG into a WebP.
Use it when:
- you want a chart that reads like a printed page, not a dashboard
- the chart must work on a light page and a dark one
- you have
themes.jsonorshare.jsonfromtext-rule-trends, or any series over periods
Requirements
- Python 3.8+, no packages for the SVG/HTML. Pillow (
pip install pillow) for WebP only. - Node 18+ and Playwright with Chromium for PNGs. In the folder you run from:
npm i playwright && npx playwright install chromium.render.mjslooks for Playwright in the working directory, then inNODE_PATH, then beside the script.PLAYWRIGHT_MODULEchooses a package folder. - Each Playwright version wants its own Chromium build, and a plain
npm i playwrightinstalls the newest. If that build is missing, runnpx playwright install chromiumwith the same version, pin a version that matches your browsers (e.g.npm i playwright@1.56.1for Chromium build 1194), or setCHROMIUM_PATHto any installed Chromium or Chrome. WithoutCHROMIUM_PATH,render.mjsalso tries any Chromium Playwright downloaded earlier (PLAYWRIGHT_BROWSERS_PATH,~/.cache/ms-playwright) and says which it used. - The page loads the ET Book font from
cdn.jsdelivr.net(tufte-css 1.8.0). Offline, it falls back to Palatino or Georgia.
The rules
Apply these to any chart, drawn with these scripts or not.
- Paper and ink. Ink on whatever page the chart sits on: a transparent background, text
from near-black to mid grey (near-white to light grey in dark mode). The classic look is
#fffff8paper with text#111to#777. A serif face (ET Book, then Palatino or Georgia). No boxes, panels or shadows. - No chart junk. No gridlines, no legend box, no background bands, no 3D, no ticks beyond what is read. A range frame for the y axis: a thin line from the lowest to the highest tick. Bin edges as short ticks on the x axis, the label at the bin centre.
- Label directly. Name each series at its end, with a thin leader to it when labels must be spread apart. Put the colour key inside a sentence ("red grew, blue shrank").
- Colour only what changed. Red
#a33a2cfor what grew, blue#4f7392for what shrank, two light greys (#ddd8cd,#e7e3da, alternating) for what stayed within the threshold (±15% by default). Decide the threshold before you look. Vary opacity a little (0.38 to 0.56) so neighbouring bands of one colour stay apart. Separate bands with a 1 px paper-coloured stroke. In dark mode the same roles get lighter values (below), never new hues. - Do not invent data between bins. Points sit at bin centres. Between them, a monotone cubic (Fritsch–Carlson), which never overshoots a measured value. Hold the curve flat to the outer bin edges. A bin with no data is skipped, not drawn as zero. A plain polyline with each value printed is also fine; it breaks where a value is missing.
- One annotation. A single curved arrow for the main claim, with its numbers.
- Context stays faint. Dated events are dotted hairlines at about 28% opacity, with small grey labels in up to three rows. One event that matters to the argument may be a solid red line.
- Show n. Print the count behind each bin under the axis in small grey.
- Same scale when comparing. Series that will be compared share one y range. A stacked chart
starts at zero; a stream chart has no zero, so it drops the y axis. Undrawn parts of a total
(
rest) are marked by a dashed line where the bands begin. - Methods in the footnote. Small grey italic (12 px): where the data comes from, how many items, how bins are made, how values between bins are drawn, and what the chart cannot show.
- Words carry the claim. The title says what happened; the subtitle gives two or three numbers. Every number in the text comes from the data through a placeholder, so prose and chart cannot drift apart. Negative numbers use the true minus sign (−).
Steps
Run the scripts by their path and write outputs to a folder of your own. SKILL is this skill's folder.
python3 $SKILL/scripts/draw_stack.py --spec stack.spec.json --out stack.html [--svg stack.svg] [--paper]
python3 $SKILL/scripts/draw_lines.py --spec lines.spec.json --out lines.html
python3 $SKILL/scripts/draw_slope.py --spec slope.spec.json --out slope.html
python3 $SKILL/scripts/draw_multiples.py --spec multiples.spec.json --out multiples.html
node $SKILL/scripts/render.mjs stack.html stack.png # stack.light.png and stack.dark.png, 2x
python3 $SKILL/scripts/to_webp.py stack.light.png stack.light.webp --width 1600
python3 $SKILL/scripts/to_webp.py stack.dark.png stack.dark.webp --width 1600render.mjs options: --selector "#fig", --width 1240 (viewport), --scale 2,
--theme auto|light|dark (below). to_webp.py keeps the transparency of the light and dark PNGs.
Light and dark mode
- The default background is transparent. The SVG (inline in the HTML, or the
--svgfile) carries both palettes as CSS custom properties (--tc-ink,--tc-red, ...) in a<style>inside it, switched by@media (prefers-color-scheme: dark). So the same file adapts on its own, shown inline or as an<img>. Inline, the host page can force one withdata-theme="light"or"dark"on the<svg>, or override any--tc-*property, e.g.--tc-gap(the separator and halo colour,#fffff8light and#111dark) to match its own background. - Contrast. Every text colour meets WCAG AA (4.5:1) on
#fffff8and white in light mode and on#111in dark mode; the faint greys of the classic look (#999counts,#a8a39aevent labels) are darkened for that. Red#a33a2cbecomes#e2735f, blue#4f7392becomes#86acd0, the steady greys become#3a3833and#46433d, and ink becomes#ecebe4. A line chart's seriescolorfollows dark mode when it is a palette name (ink,red,blue,grey) or a palette hex; any other colour is kept as given in both modes. - PNG and WebP.
render.mjs --theme auto(the default) writesname.light.pngandname.dark.png, each with a transparent background and its palette.--theme lightordarkwrites the one file you name. - The classic paper look.
--paperon any draw script (or"background": "paper"in the spec) draws the#fffff8page with one palette and no dark mode, byte for byte as before this option existed (one exception: a stack chart with no title, subtitle, key, events or quote now closes up the empty header band).render.mjsthen writes the one PNG you name. Use it to reproduce older figures. On a website, serve the pair with
<picture>:<picture> <source srcset="chart.dark.webp" media="(prefers-color-scheme: dark)"> <img src="chart.light.webp" alt="What the chart shows, in one sentence" width="1200" height="1040"> </picture>Or use the SVG itself (
<img src="chart.svg">), which switches on its own; it falls back to Palatino or Georgia, because an SVG shown as an image cannot load the web font.
Specs
Every spec has "data": an object, or the name of a JSON file beside the spec. Periods can be
quarters (2026Q1), months (2026-03), years (2026), days (2026-03-15) or any labels; dated
periods sit on a true time scale (and allow events and quote), other labels are evenly spaced.
Every spec may also set "background": "transparent" (the default, light and dark) or
"paper" (the same as --paper). Each script's --help lists every key. In short:
| script | data | main keys |
draw_stack.py | {q, groups: [{name, v, kind?}], tot?, nfiles?, from_q?, to_q?, rest?}, bottom to top | title, subtitle, key ([red]..[/], [blue]..[/]), ylabel, share_label, count_label, arrow, quote, events, footnote, threshold, y_step, baseline (zero or center), layout (plot_height 560: the stack's top is fitted to it; max_scale: optional cap in px per unit) |
draw_lines.py (y ticks and values print as plain numbers by default; set y.tick and value_format to "{:.0f}%" for shares) | anything; each series has values, or from a key ({"key": "tot"} reads text-rule-trends' pooled.json directly; add "sample" for share.json), or from a share of counts | periods, series [{label, color, ...}], y {min, max, step, tick, reference}, value_format, count_label, events, quote, footnote |
draw_slope.py | {items: [{name, from, to}]}, or stacked data plus left, right, measure | title, left_label, right_label, value_format, threshold, footnote |
draw_multiples.py | {q, series: [{name, v}]}, or stacked data | columns, panel, y, value_format, order, threshold, footnote |
Placeholders: the stack chart fills total_from, total_to, change_pct, p_from, p_to,
n_total (and the older tot_from, tot_to, q_from, q_to, files). The line chart fills
{a[i]}, {b[i]}, ... (each series' values, i over the periods drawn) and {a_last} (the last
value that is not null). Both fill {data[...]} from the data file, e.g.
{data[samples][<sample name>][files]:,} or {data[files_with_items]:,} from text-rule-trends'
themes.json, so counts in a footnote come from the data too. Use Python format specs:
{change_pct:+.0f}.
Layout details the scripts handle: if either compared bin is empty (or its shares are null), the
stack chart draws no arrow, shows those shares as "–" and colours the bands as too few. A rising
arrow gets headroom on the y axis. With no events and no quote, the stack and line charts close
up the row kept for them (set layout.keep_event_row to keep it). A missing spec or data file
stops with its path, and a placeholder the chart does not have is named with the list of those it
has. Missing values (an empty bin) print as "–". The slopegraph stops with a message if a compared
bin is empty. A value label at the floor of a line chart goes above its point, off the x axis. The stack chart measures its label column, and puts each share
on a line under its name (wrapping very long names) when a name would run into its share. It
lifts the arrow's words clear of the bands. Empty bins (0 files or null values) are skipped. The
line chart prints counts only for series that have them. The slopegraph draws a thin leader from
each end of a line to its label, so tied values pushed apart stay readable. Small multiples sit on
a true time scale with a faint tick per period.
A group with no kind gets one from its share of the total between from_q and to_q. too few
(from text-rule-trends, when a compared bin is under 20 files) is drawn grey like steady.
Examples
README sections, from public data (examples/readme-sections/)
themes.data.json is the output of text-rule-trends/examples/readme-sections.
stack.spec.json, slope.spec.json and multiples.spec.json draw it three ways: headings per
README rose from 13.0 to 15.8 (2016 to 2025), with "API reference entries" the one theme that grew.
Made-up lines with gaps (examples/made-up/lines.spec.json)
Monthly values with nulls, a negative value and a null last value. The lines break at the gaps, the label sits at each series' last known value, and −3 prints with a true minus.
Smoke test
python3 $SKILL/scripts/draw_stack.py --spec $SKILL/examples/readme-sections/stack.spec.json --out /tmp/tc/stack.html
python3 $SKILL/scripts/draw_lines.py --spec $SKILL/examples/made-up/lines.spec.json --out /tmp/tc/lines.html
node $SKILL/scripts/render.mjs /tmp/tc/stack.html /tmp/tc/stack.png # stack.light.png, stack.dark.pngdraw_stack.py prints stack top 15.80, 31.1px per unit, 7 bins with data; draw_lines.py prints
series A 42, 48, -, 61, −3, -; series B 30, 33, 35, -, 41, 44. The two PNGs are 2400 px wide,
with transparent backgrounds.
The preview (assets/preview.svg)
examples/readme-sections/preview.spec.json draws the README example at 600x290 with no title:
draw_stack.py --spec $SKILL/examples/readme-sections/preview.spec.json --out preview.html --svg preview.svg.
Worked example. These skills were first written for the agentleFS article "CLAUDE.md best
practices" (https://agentlefs.com/blog/claude-md-best-practices), which studied 5,786 CLAUDE.md and
AGENTS.md files. Its data, settings and step-by-step recipe live outside the skills, in the
article-claude-md-best-practices folder (RECIPE.md and data/). Its figures use the classic
paper look, so the recipe passes --paper.
Limits and caveats
- Layouts are tuned for about 12 bands, 6 to 8 bins and labels up to about 40 characters at
1200 px wide. Change
layoutin the spec for other shapes. - The slopegraph and the small multiples size themselves to the items; very long names need a
wider
layout. - Fonts come from a CDN. Offline rendering changes the face and text widths slightly.
- PNGs are identical only on the same Chromium build; fonts and antialiasing differ across builds.
- A transparent PNG's band separators and label halos are drawn in
--tc-gap(#fffff8or#111), so on a page of a very different colour they show as faint lines. Use the SVG inline and set--tc-gap, or render with--paper.
Credits
- Edward Tufte, The Visual Display of Quantitative Information (Graphics Press, 1983; 2nd ed. 2001). The principles only: maximise data-ink, no chart junk, range frames, direct labels, small multiples and the slopegraph. No text or figure is copied from the book.
- ET Book, the typeface, by Dmitry Krasny, Bonnie Scranton and Edward Tufte (MIT licence), loaded from the tufte-css 1.8.0 package by Dave Liepmann (MIT licence) on jsDelivr. It is linked, not bundled.
- F. N. Fritsch and R. E. Carlson, "Monotone Piecewise Cubic Interpolation", SIAM Journal on Numerical Analysis 17(2), 1980: the slopes that keep the curve between bins from overshooting.
- Playwright (Apache-2.0) and Pillow (MIT-CMU) are used as installed tools, not bundled.

