agentleFS
Registry

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.

Mount
SKILL.md

Watercolor 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.mjs checks the input before it starts a browser: every value must be a number or null, and every series must have one value per period (per entry in q, if given). Otherwise it exits with status 2 and says which series and value are wrong.
  • Stacked data (groups or series, bottom to top) is what text-rule-trends writes as themes.json. v is one value per period. kind and color are optional.
  • A matrix is rows of values, all rows the same length. null is 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:

  1. its own color, if it has one;
  2. 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);
  3. otherwise the next colour of the named palette or list (earth when the palette is kind), 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 own color, 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 style

Options: --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 #f7f4ec with 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 use paper for light pages.
  • --background dark: the same lifted pigments on a flat #111 ground, 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 --cell px 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). null before 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 1200x420

The 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 color is lifted too on transparent and dark; pick colours that survive it, or render that image with paper.

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 rothko style'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.