Watercolor data art
skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-12
Paint generative watercolor art from a chart's own data, after Tyler Hobbs' published recipe, on paper, transparent or dark.
MountWatercolor data art
Paints data as an abstract watercolor picture, where every square or band sits where the data puts it.
Image: Preview
It is not a chart: no axes, no labels. Styles: a grid of soft squares with faint ghost squares for what each series lost since its peak, Rothko-like blurred bands, random-size squares, and a matrix of squares sized by value. The palette comes from the data, a list of colours, a named palette, or the kind of each series (rise, fall, steady). Seeded and reproducible, at any size, on warm paper, on a transparent canvas or on a dark ground.
Use it when you want an image that comes from the data rather than decoration, next to the real chart or with a caption that says what it shows.
Requirements
Node 18+ and Playwright with Chromium. In the folder you run from:
npm i playwright && npx playwright install chromium. render.mjs looks for Playwright in the
working directory, then in NODE_PATH, then beside the script. PLAYWRIGHT_MODULE chooses a
package folder.
Each Playwright version wants its own Chromium build. A plain npm i playwright installs the
newest, which may want a build you do not have. Either run npx playwright install chromium with
that same version, pin the version that matches your browsers (e.g. npm i playwright@1.56.1
for Chromium build 1194), or set CHROMIUM_PATH to any installed
Chromium or Chrome. Without CHROMIUM_PATH, render.mjs also tries any Chromium Playwright
downloaded earlier (in PLAYWRIGHT_BROWSERS_PATH or ~/.cache/ms-playwright) and says which one
it used. Pixel-identical output needs the same Chromium build. No Python is needed (for WebP, use tufte-charts/scripts/to_webp.py).
Inputs
A JSON file, one of:
{"q": ["2025Q2", "..."], "groups": [{"name": "...", "v": [1.4, "..."], "kind": "rise", "color": "#a33a2c"}]}
{"series": [{"name": "...", "v": [...]}], "palette": "sea"}
{"matrix": [[3, 5, null], [1, 0, 2]], "rows": ["..."], "cols": ["..."], "palette": ["#264668", "#4a688a"], "diverging": false}render.mjschecks the input before it starts a browser: every value must be a number ornull, and every series must have one value per period (per entry inq, if given). Otherwise it exits with status 2 and says which series and value are wrong.- Stacked data (
groupsorseries, bottom to top) is whattext-rule-trendswrites asthemes.json.vis one value per period.kindandcolorare optional. - A matrix is rows of values, all rows the same length.
nullis an empty cell, painted as a ghost. 0 is left blank. Square sides grow with the square root of |value|. Colour is by row, or by sign with"diverging": true.
Palette. A named --palette (or a list) overrides the colours by kind: kinds no longer pick
colours, and every series without its own color takes the palette in turn.
--palette or the data's palette is kind, a named palette (earth, sea,
dusk, ink), or a list of colours (#hex or r,g,b). The default is kind when any series
has a kind, else earth. Each series gets, in order:
- its own
color, if it has one; - with
kind: the next shade of its kind (warm earth for rise, cool ink for fall, sand for steady or too few; 5 shades each); - otherwise the next colour of the named palette or list (
earthwhen the palette iskind), counted over these series only. So a series with no kind and no colour never repeats a colour that another series took by kind or by its owncolor, until the palette runs out.
Steps
Write outputs to a folder of your own. SKILL is this skill's folder.
node $SKILL/scripts/render.mjs data.json grid.png # grid, the default
node $SKILL/scripts/render.mjs data.json rothko.png --style rothko
node $SKILL/scripts/render.mjs data.json squares.png --style squares --seed 7 --size 1200x420
node $SKILL/scripts/render.mjs matrix.json matrix.png --size 1500x560 # matrix data picks the matrix styleOptions: --style grid|rothko|squares|matrix, --seed N (default 20261010), --size WxH
(default 2400x840), --palette ..., --ghosts running|global|none (grid only, default running),
--cell PX (square size; default 26 for grid, fitted for matrix), --max-scale N,
--background paper|transparent|dark (default paper).
Light and dark
--background paper(default): warm paper#f7f4ecwith a faint grain, and the pigments as given. This is how every image before this option was made, pixel for pixel.--background transparent: no paper and no grain; the canvas stays clear, so the PNG has an alpha channel and sits on any page. The pigments are lifted for a dark ground: each colour keeps its hue, its lightness is raised to at least 0.62 and its saturation by 15%. On a white page they look pastel, so usepaperfor light pages.--background dark: the same lifted pigments on a flat#111ground, no grain.- All three draw the same squares, edges and jitter for the same seed (the grain's random numbers are drawn even when no grain is painted), so a light and a dark image line up shape for shape.
Pair them on a website with <picture>, and give both the same seed, size and options:
node $SKILL/scripts/render.mjs data.json art.light.png
node $SKILL/scripts/render.mjs data.json art.dark.png --background dark<picture>
<source srcset="art.dark.webp" media="(prefers-color-scheme: dark)">
<img src="art.light.webp" alt="Watercolor of the data: what it shows" width="1200" height="420">
</picture>A small SVG instead of a PNG. scripts/preview_svg.py DATA.json OUT.svg [--seed N]
[--size 600x210] [--cell 28] [--layers 3] draws the grid style in miniature from the same
stacked data: translucent layers of deformed squares, no paper, no ghosts and no masks, with both
palettes in the file as CSS custom properties switched by prefers-color-scheme, so one file
serves light and dark pages. Python standard library only; the same data and seed give the same
bytes. assets/preview.svg is
python3 $SKILL/scripts/preview_svg.py $SKILL/examples/made-up/stack.json preview.svg (about 35 KB).
Scale. By default the picture is fitted to the canvas: the tallest thing drawn (the stack, plus
the ghosts above it in grid) fills the height, whatever the units, so small totals do not make a
thin strip. --max-scale N uses the older rule instead: at most N px per unit, sized by the stack
alone. Images made before the auto-fit used --max-scale 54 (grid) and --max-scale 50 (rothko). The script saves the canvas
pixels, not a screenshot.
How it works
- Grid. The series are stacked from the floor, time left to right, with cosine easing between
periods, scaled as described under Scale. An even grid of
--cellpx covers the picture. Each cell whose centre falls inside a band becomes a square in that series' colour, with ±6 levels of colour jitter. Above the curve, stacked in the same order, each series gets what it has lost since its peak: those cells become ghost squares, painted 20 layers at 1.2% opacity instead of 50 layers at 3.5%.--ghosts running(default): the peak so far, up to that x. Nothing is lost before a series peaks.--ghosts global: the series' overall peak at every x, so ghosts also appear before the peak. Use it to reproduce images made that way.--ghosts none: no ghosts.
- Rothko. Each series is a band, laid 36 times at 5% opacity with a blur of 8 to 30 px. Edges wobble a little on each layer, over a warm blurred ground.
- Squares. Random-size squares on a fine grid, the biggest placed first, each wholly inside one band, with calm edges.
- Matrix. One square per cell, centred, sized by value; empty cells are ghosts. Big cells get calmer edges so they stay square.
- The watercolor method is Tyler Hobbs', from "A Generative Approach to Simulating Watercolor
Paints" (2017), https://tylerxhobbs.com/essays/2017/a-generative-approach-to-simulating-watercolor-paints :
- recursive midpoint deformation, where each edge carries its own variance and passes a slightly smaller, randomised variance to its children
- a base polygon deformed once, then deformed further for each layer
- many layers at low opacity, each layer masked by many small random circles
- colours interleaved five layers at a time, so they mix instead of stacking
- Where this differs from the essay. The base polygon gets 1 round of deformation here, not about 7. The mask circles are small (radius |N(0,1)| × 1.1% of the width + 0.2%), and each layer uses 1,400 of them. Both keep small squares recognisable as squares; with the essay's settings they melt into blobs. The method is Hobbs'. The look (data grids, these palettes, ghost squares) is ours and is not meant to imitate his work.
- Seeding. One linear congruential generator (
seed * 1664525 + 1013904223 mod 2^32) drives everything, in a fixed order: paper grain (drawn but not painted on transparent and dark), deformation, masks and colour jitter.
Examples
examples/readme-sections/matrix.json: README section headings in five public repos, one row per repo, one column per year (2011 to 2025).nullbefore a repo had a README.examples/made-up/stack.json: four made-up series. Two have a kind, one has its own colour, and one has neither, so it takes the first palette colour. Try it with every style and a few palettes.
Run any example twice with the same options: the PNGs are identical in every pixel.
Smoke test (about 10 s):
node $SKILL/scripts/render.mjs $SKILL/examples/made-up/stack.json /tmp/wda/grid.png --size 1200x420
node $SKILL/scripts/render.mjs $SKILL/examples/made-up/stack.json /tmp/wda/grid2.png --size 1200x420The two PNGs are identical in every pixel. Warm reds for "growing", plum for "spiky", blues for
"shrinking" and sand at the floor; faint ghost squares appear only after "shrinking" peaks.
Add --background dark for the same squares in lifted colours on #111.
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/).
Limits and caveats
- Reproducible on the same Chromium build. Another GPU or Skia version can change antialiasing by a few levels, so do not compare pixel hashes across machines.
- Named palettes have 5 to 7 colours, and the kind palette 5 shades per kind; more series repeat colours.
- A big canvas takes longer: about 12 s for grid and 18 s for rothko at 2400x840.
- It is a picture of the data, not a substitute for the chart. Publish it with the real chart or a caption. Credit Tyler Hobbs for the watercolor method when you publish the images.
- The lifted pigments are one rule for every colour. A series' own
coloris lifted too on transparent and dark; pick colours that survive it, or render that image withpaper.
Credits
- Tyler Hobbs, "A Generative Approach to Simulating Watercolor Paints" (2017), https://tylerxhobbs.com/essays/2017/a-generative-approach-to-simulating-watercolor-paints . The method is his: recursive midpoint deformation with per-edge variance, a base polygon deformed further for each layer, many low-opacity layers each masked by random circles, and colours interleaved a few layers at a time. Our deviations: 1 round of base deformation instead of about 7, small mask circles (1,400 a layer), dark-ground pigments, and the SVG preview, which drops the masks. The look (data grids, ghost squares, these palettes) is ours and is not meant to imitate his work. The code is our own, written from the essay's description; no code or image of his was copied. The essay's licence terms were not checked (the page did not answer on 2026-10-11).
- Mark Rothko: the
rothkostyle's soft stacked colour fields refer to his paintings by name and idea only. - Playwright (Apache-2.0) and Chromium are used as installed tools, not bundled.

