agentleFS
Registry

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.

Mount
scripts/render.mjs
// render.mjs IN.html OUT.png [--selector "#fig"] [--width 1240] [--scale 2] [--theme auto|light|dark]
//
// Opens the page in headless Chromium, waits for web fonts, and screenshots one element.
//
// --theme, for a chart drawn with the default transparent background (it carries light and dark
// palettes): auto (default) writes two PNGs with transparent backgrounds, OUT.light.png and
// OUT.dark.png; light or dark writes OUT alone in that palette. A chart drawn with --paper (or any
// other page) has one look: auto writes OUT exactly as before, and light or dark only sets the
// page's prefers-color-scheme.
//
// Playwright is looked up in this order:
//   1. PLAYWRIGHT_MODULE: a package name or a path to an installed package folder
//   2. "playwright", then "@playwright/test", resolved from the current working directory
//      (so `npm i playwright` in the folder you run from works)
//   3. the same names in each NODE_PATH folder, then next to this script
// Chromium: CHROMIUM_PATH if set (any installed Chromium or Chrome); else Playwright's own build;
// else any Chromium Playwright downloaded earlier (PLAYWRIGHT_BROWSERS_PATH, ~/.cache/ms-playwright).
// A plain `npm i playwright` installs the newest version, which may want a Chromium build you do
// not have: pin the version that matches your browsers (e.g. npm i playwright@1.56.1) or set
// CHROMIUM_PATH. Pixel-identical output needs the same Chromium build.
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

function loadPlaywright() {
  const bases = [process.cwd(), ...(process.env.NODE_PATH || "").split(path.delimiter).filter(Boolean), path.dirname(new URL(import.meta.url).pathname)];
  const names = process.env.PLAYWRIGHT_MODULE ? [process.env.PLAYWRIGHT_MODULE] : ["playwright", "@playwright/test"];
  const tried = [];
  for (const base of bases) {
    const req = createRequire(path.join(path.resolve(base), "noop.js"));
    for (const n of names) {
      try { return req(n); } catch (e) { tried.push(`${n} from ${base}`); }
    }
  }
  console.error("Cannot find Playwright. Tried:\n  " + tried.join("\n  ") +
    "\nFix: run `npm i playwright && npx playwright install chromium` in the folder you run from," +
    "\nor set PLAYWRIGHT_MODULE=/path/to/node_modules/playwright (and CHROMIUM_PATH=/path/to/chrome if needed).");
  process.exit(1);
}

// Chromium builds Playwright has downloaded before, newest first: used when the installed
// Playwright wants a build that is not there (e.g. a newer `npm i playwright` than the browsers).
function installedChromiums() {
  const roots = [process.env.PLAYWRIGHT_BROWSERS_PATH, path.join(os.homedir(), ".cache", "ms-playwright"),
    path.join(os.homedir(), "Library", "Caches", "ms-playwright")].filter(Boolean);
  const rel = ["chrome-linux/chrome", "chrome-linux64/chrome", "chrome-mac/Chromium.app/Contents/MacOS/Chromium",
    "chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium", "chrome-win/chrome.exe"];
  const found = [];
  for (const r of roots) {
    let dirs = [];
    try { dirs = fs.readdirSync(r).filter(d => /^chromium-\d+$/.test(d)); } catch { continue; }
    dirs.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
    for (const d of dirs) for (const x of rel) { const p = path.join(r, d, x); if (fs.existsSync(p)) found.push(p); }
  }
  return found;
}

async function launch(pw) {
  if (process.env.CHROMIUM_PATH) return pw.chromium.launch({ executablePath: process.env.CHROMIUM_PATH });
  try { return await pw.chromium.launch(); } catch (e) {
    for (const p of installedChromiums()) {
      try { const b = await pw.chromium.launch({ executablePath: p }); console.error(`note: Playwright's own Chromium is missing; using ${p}`); return b; } catch {}
    }
    console.error("Playwright found, but Chromium did not start:\n" + String(e.message).split("\n")[0] +
      "\nFix: npx playwright install chromium (with the same Playwright version), or set CHROMIUM_PATH" +
      "\nto any installed Chromium or Chrome binary.");
    process.exit(1);
  }
}

const args = process.argv.slice(2);
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args.splice(i, 2)[1] : dflt; };
const selector = opt("--selector", "#fig");
const width = Number(opt("--width", "1240"));
const scale = Number(opt("--scale", "2"));
const theme = opt("--theme", "auto");
const [input, output] = args;
if (!input || !output || !["auto", "light", "dark"].includes(theme)) {
  console.error("usage: render.mjs IN.html OUT.png [--selector #fig] [--width 1240] [--scale 2] [--theme auto|light|dark]");
  process.exit(2);
}

if (!fs.existsSync(input)) { console.error(`no such file: ${input} (make it first with one of the draw_*.py scripts)`); process.exit(2); }
const pw = loadPlaywright();
const browser = await launch(pw);
const url = pathToFileURL(path.resolve(input)).href;

async function open(scheme) {
  const page = await browser.newPage({ viewport: { width, height: 1200 }, deviceScaleFactor: scale, ...(scheme ? { colorScheme: scheme } : {}) });
  await page.goto(url, { waitUntil: "networkidle" });
  await page.evaluate(() => document.fonts.ready);
  return page;
}
async function shot(page, file, transparent) {
  if (transparent) await page.addStyleTag({ content: "html,body{background:transparent!important}" });
  await page.locator(selector).first().screenshot({ path: file, omitBackground: transparent });
  await page.close();
  console.log(`wrote ${file}`);
}

const first = await open(theme === "auto" ? null : theme);
const themed = await first.evaluate(sel => { const el = document.querySelector(sel); return !!el && (el.matches(".tc-chart") || !!el.querySelector(".tc-chart")); }, selector);
if (!themed) {
  if (theme !== "auto") console.error(`note: ${input} has one palette (drawn with --paper, or not a tufte-charts page); --theme ${theme} only sets prefers-color-scheme`);
  await shot(first, output, false);
} else if (theme !== "auto") {
  await shot(first, output, true);
} else {
  const base = output.replace(/\.png$/i, "");
  await shot(first, `${base}.light.png`, true);       // the default scheme is light
  await shot(await open("dark"), `${base}.dark.png`, true);
}
await browser.close();