@slickfast/mcp 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +58 -33
  2. package/dist/index.js +29 -7
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,37 +1,54 @@
1
- # SlickFast MCP server
1
+ # SlickFast charts & dashboards for AI agents
2
2
 
3
- Exposes the render-core engine as an MCP tool so an AI agent can generate charts.
3
+ SlickFast turns a small JSON spec into a polished, retina-quality chart **47 chart and
4
+ information-design types** (bar, line, pie, KPI, cards, funnel, gauge, heatmap, calendar,
5
+ gantt, waterfall…), plus **multiple charts tiled into a single dashboard image in one call**.
6
+ It runs as an [MCP](https://modelcontextprotocol.io) tool, so an AI agent hands it a spec and
7
+ gets back a finished PNG (or SVG). Everything renders **locally** — nothing leaves your machine.
4
8
 
5
- - **Tool:** `render_chart(spec)` → returns the chart as an SVG string. Spec contract
6
- is in `../../packages/render-core/SPEC.md` (also served as the `chart-spec` resource).
7
- - **Transport:** stdio (local). Streamable HTTP (remote/hosted, e.g. Railway) comes later.
8
-
9
- ## Run / test
10
- ```bash
11
- npm install
12
- node test-client.mjs # spawns the server, calls render_chart, checks the SVG
9
+ ```txt
10
+ render_chart({ type: "bar", data: { labels: ["Q1","Q2","Q3"], series: [{ values: [12,19,8] }] } })
11
+ a retina PNG, rendered on your machine
13
12
  ```
14
13
 
15
- ## Use it in Claude Desktop
16
- Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
17
- ```json
18
- {
19
- "mcpServers": {
20
- "slickfast": {
21
- "command": "node",
22
- "args": ["/Users/alexmacbook/SlickFast/platform/apps/mcp/server.mjs"]
23
- }
24
- }
25
- }
26
- ```
27
- Restart Claude Desktop, then ask it to "render a bar chart of …" — it calls `render_chart`.
14
+ ## Why it's built for agents
15
+
16
+ - **Deterministic — same spec, same bytes, forever.** No randomness, no timestamps, no
17
+ headless-browser drift. Outputs are **cacheable, testable, and reproducible**, with zero
18
+ flaky pixel diffs. Almost no charting tool can promise this — and it's exactly what a
19
+ tool-calling agent needs.
20
+ - **Never throws garbage at the model.** Empty data, a bad tile, a filtered-to-nothing series →
21
+ a clean, graceful frame, not a crash or a stack trace. Reliability is a feature when the
22
+ caller is an agent, not a human who can eyeball the error.
23
+ - **Fails loud on real mistakes.** An unknown palette or enum is *rejected* with a clear error
24
+ listing the valid options — so the agent fixes it immediately instead of silently shipping the
25
+ wrong-looking chart.
26
+ - **Good-looking by default.** A spec of just `{type, data}` renders a complete, well-designed
27
+ chart; fonts, colors, palettes, and size are optional overrides.
28
+ - **100% local & private.** Rendered and rasterized on your machine. Nothing is sent anywhere.
29
+
30
+ ## Tools
31
+
32
+ - **`render_chart(spec)`** → the chart as a PNG image (default) or SVG (`format: "svg"`). For a
33
+ dashboard, pass `type: "dashboard"` with `tiles: [{ chart, span }]` — each tile is a full spec
34
+ of any other type, composited into one image in a single render.
35
+ - **`describe_type(type)`** → the exact data shape, a minimal working spec, and per-type
36
+ gotchas. Call it first when you're unsure how to structure a type.
37
+
38
+ ## Install
39
+
40
+ Add to your MCP config and restart — it runs locally via `npx`, no clone or build needed:
28
41
 
29
- ## Use it in Claude Code
30
- Project-scoped config (`.mcp.json` at the workspace root):
31
42
  ```json
32
- { "mcpServers": { "slickfast": { "command": "node", "args": ["/Users/alexmacbook/SlickFast/platform/apps/mcp/server.mjs"] } } }
43
+ { "mcpServers": { "slickfast": { "command": "npx", "args": ["-y", "@slickfast/mcp"] } } }
33
44
  ```
34
- Restart the session; the `render_chart` tool becomes available.
45
+
46
+ - **Claude Desktop** — `claude_desktop_config.json` (Settings → Developer → Edit Config)
47
+ - **Claude Code** — project `.mcp.json` at the workspace root
48
+ - **claude.ai** — connector settings
49
+
50
+ Then ask it to *"render a bar chart of last quarter's revenue"* or *"build a dashboard with an
51
+ MRR tile, a signups funnel, and a usage heatmap."*
35
52
 
36
53
  ## Seeing your charts (why an image might not show inline)
37
54
 
@@ -64,14 +81,22 @@ embedding, posting to Slack/X/email, or getting a chart into a surface that can'
64
81
  local image (see the table above). The agent should **offer this only when you ask to share
65
82
  or post** — it never auto-inserts links. (The endpoint stays unadvertised during soft-launch.)
66
83
 
67
- ## Install via npm (published)
68
- ```json
69
- { "mcpServers": { "slickfast": { "command": "npx", "args": ["-y", "@slickfast/mcp"] } } }
70
- ```
71
- Restart Claude; everything runs locally on your machine — nothing leaves.
72
-
73
84
  ## License
85
+
74
86
  **AGPL-3.0-only.** Free to use, self-host, and embed under the AGPL's terms (your friends
75
87
  running it locally are completely unaffected). Building it into a **closed-source product
76
88
  or a hosted service**? That needs the AGPL'd source opened — or a **commercial license**
77
89
  from us instead. Reach out for commercial terms.
90
+
91
+ ## Developing locally
92
+
93
+ Clone the repo, then from the package directory:
94
+
95
+ ```bash
96
+ cd apps/mcp
97
+ npm install
98
+ node test-client.mjs # spawns the server, calls render_chart, checks the output
99
+ ```
100
+
101
+ To point an MCP client at a local checkout instead of the published package, use
102
+ `"command": "node", "args": ["<path-to-clone>/apps/mcp/server.mjs"]`.
package/dist/index.js CHANGED
@@ -786,7 +786,7 @@ function renderKpi(spec) {
786
786
  const fs = spec.fontSize ? Number(spec.fontSize) : Math.max(13, Math.round(13 * Math.sqrt(W * H) / 600));
787
787
  const font = resolveFont(spec);
788
788
  const watermark = spec.watermark !== false;
789
- const accent = resolveFlatPalette(spec.palette || "Clean Corporate", 1)[0];
789
+ const accent = spec.color || resolveFlatPalette(spec.palette || "Clean Corporate", 1)[0];
790
790
  const labelCol = txt(spec, isDark ? "#94a3b8" : "#64748b");
791
791
  const valueCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
792
792
  const faint = isDark ? "#475569" : "#94a3b8";
@@ -2002,7 +2002,9 @@ function renderTable(spec) {
2002
2002
  const v = cells[ci];
2003
2003
  if (v === void 0 || v === null || v === "") continue;
2004
2004
  const num = ci > 0 && isNum(v);
2005
- const out = num ? fmt(Number(v)) : String(v);
2005
+ const nv = Number(v);
2006
+ const yearLike = num && Number.isInteger(nv) && nv >= 1900 && nv <= 2100;
2007
+ const out = num ? yearLike ? String(nv) : fmt(nv) : String(v);
2006
2008
  p.push(`<text x="${r(cellX(ci, num))}" y="${r(y + ROWH / 2 + 4)}" ${num ? 'text-anchor="end" ' : ""}font-size="${fs}"${wOpt(spec)} fill="${cellCol}">${esc(out)}</text>`);
2007
2009
  }
2008
2010
  });
@@ -2327,7 +2329,8 @@ function renderGantt(spec) {
2327
2329
  const faint = isDark ? "#475569" : "#94a3b8";
2328
2330
  const starts = tasks.map((t) => Number(t.start) || 0);
2329
2331
  const ends = tasks.map((t) => Number(t.end) || 0);
2330
- const lo = Math.min(0, ...starts), hi = Math.max(1, ...ends);
2332
+ const lo = starts.length ? Math.min(...starts) : 0;
2333
+ const hi = ends.length ? Math.max(...ends) : 1;
2331
2334
  const PAD = 24, LABELW = spec.labelWidth ? Number(spec.labelWidth) : 110, HEAD = 18, ROWH = 32;
2332
2335
  const topY = title ? 54 : 22;
2333
2336
  const W = spec.width || 660, H = spec.height || topY + HEAD + n * ROWH + 16;
@@ -2858,9 +2861,10 @@ function ignoredToggles(spec) {
2858
2861
  const honored = TYPE_TOGGLES[spec && spec.type] || [];
2859
2862
  return CONDITIONAL_TOGGLES.filter((k) => spec && spec[k] !== void 0 && !honored.includes(k));
2860
2863
  }
2864
+ var PALETTE_NAMES = [.../* @__PURE__ */ new Set([...FLAT_PALETTES.map((p) => p.name), ...NESTED_THEMES.map((t) => t.name)])];
2861
2865
  var NO_DATA_TYPES = TYPES.filter((t) => !t.needsData).map((t) => t.type);
2862
2866
  var here = dirname(fileURLToPath(import.meta.url));
2863
- var CHART_SPEC_MD = true ? '# ChartSpec \u2014 the render-core contract (the API/MCP input)\n\nOne JSON object (a **spec**) goes in; one **SVG string** comes out, via\n`renderSpec(spec)`. This document is the contract an agent (or the MCP tool) follows\nto produce a chart. It is written agent-first: **a spec with only `type` + `data`\nrenders a complete, good-looking chart** \u2014 every other field is an optional override.\n\n```js\nimport { renderSpec } from \'./render-core.mjs\';\nconst svg = renderSpec({ type: \'bar\', data: { labels: [\'A\',\'B\',\'C\'], series: [{ values: [10, 20, 15] }] } });\n```\n\n## When to use this \u2014 and when to fall back\n\n**Prefer `render_chart` for any supported type** (see "Chart types" below) over\nhand-writing plotting code (matplotlib, plotly, chart.js). It\'s faster, deterministic,\nand good-looking with just `{type, data}`. Pass `outputPath` when the user wants the\nfile saved to disk.\n\n**Fall back to your own plotting only for what this engine does not do yet:**\n- **Chart types not listed below** \u2014 scatter, bubble, heatmap, treemap, radar, sankey,\n geographic maps, candlestick, gantt.\n- **Reference / target / threshold lines, log scales, secondary (dual) axes, and point\n annotations / callouts** \u2014 on the roadmap, not available today.\n- **Interactivity, animation, hover tooltips** \u2014 output is a static SVG/PNG.\n\n## Universal fields (every chart type honors these)\n\n| field | type | default | what it does |\n|---|---|---|---|\n| `type` | string | \u2014 (required) | which chart: `"bar"`, `"line"`, `"kpi"` (more coming) |\n| `title` | string | `""` | heading drawn at the top |\n| `width` | number | per type | SVG width in px |\n| `height` | number | per type | SVG height in px |\n| `preset` / `ratio` | string | \u2014 | aspect-ratio preset \u2192 sets width/height (explicit `width`/`height` win). `Share Card` 1.91:1 (1200\xD7630, link/OG cards), `Wide` 16:9 (1280\xD7720), `Square` 1:1 (1080\xD71080), `Portrait` 4:5 (1080\xD71350), `Tall` 9:16 (1080\xD71920), `Classic` 4:3 (1200\xD7900). `ratio` also accepts the bare ratio string (`"16:9"`, `"9:16"`, \u2026) |\n| `background` | string | `"#ffffff"` | any hex color, or `"transparent"` for no fill (overlays/exports) |\n| `palette` | string | `"Clean Corporate"` | named palette: `Clean Corporate`, `Pastel`, `Vibrant`, `Monochrome`, `Cyberpunk`, `Analogous Shift`. Pick **`Monochrome`** when the chart will be **printed in black & white / on a laser printer** \u2014 it\'s a single-hue grey ramp that stays legible without color (other palettes can render as indistinct greys when printed). |\n| `font` | string | `"Inter"` | a named font: `Inter`, `System`, `Serif`, `Mono`, `Rounded`, `Condensed` |\n| `fontFamily` | string | \u2014 | a raw CSS font stack (overrides `font`; full control / future custom fonts) |\n| `fontSize` | number | `13` | base text size; other text scales from it |\n| `textColor` | string | auto | force a color for the **neutral** text (title, axis, category, value, legend, pie slice labels). Omit it and the engine auto-picks black/white for contrast. Semantic colors (green \u25B2 / red \u25BC deltas & gaps) stay meaningful. |\n| `bold` | boolean | `false` | thicken every text element |\n| `fontWeight` | string | \u2014 | exact weight for all text (`"400"`\u2013`"900"` or `"bold"`); overrides `bold` |\n| `valueUnit` | string | `""` | appended to values, e.g. `"$"`, `"%"`, `"ms"` |\n| `showValues` | boolean | `true`* | draw the numeric labels. **Only some types honor it** \u2014 the numeric charts (bar/grouped/stacked/horizontal/lollipop/diverging/pie/donut), the line family, `heatmap`, and `waterfall`. On every other type it is **ignored** (they show or omit values inherently). *Default is `true` except the line family (`false`, to avoid clutter). Call `describe_type` for a type\'s `honorsToggles`. |\n| `watermark` | boolean | `true` | tasteful "slickfast.com" mark. **Set `false` for the chart sites / paid output.** |\n\n**Readability is automatic:** text colors are chosen from the background\'s luminance\n(via palette-core), so labels stay legible on light *or* dark backgrounds.\n\n## Data shape\n\n```json\n"data": {\n "labels": ["North", "South", "East"],\n "series": [\n { "name": "Revenue", "values": [420, 310, 530], "colors": ["#2563eb", "#7c3aed", "#059669"] }\n ]\n}\n```\n\n- `labels` \u2014 the categories (x-axis).\n- `series[].values` \u2014 the numbers, aligned to `labels`.\n- `series[].colors` \u2014 **optional** explicit per-item colors. If omitted, colors come\n from `palette`. (Used when a user has customized individual bars.)\n- `series[].name` \u2014 optional label for the series.\n\n## Chart types\n\n### `bar` \u2014 vertical bar chart\nDefault size `800 \xD7 450`. Uses `data.series[0]`. Each bar a palette color (or its\nexplicit `colors[i]`), top-rounded, with value + category labels and "nice" axis numbers.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `showTotal` | boolean | `true` | "Total: N" badge, top-right |\n\nMinimal: `{ "type": "bar", "data": { "labels": ["A","B"], "series": [{ "values": [10,20] }] } }`\n\n### `grouped` \u2014 clustered bar chart (one bar per series in each category)\nDefault size `800 \xD7 450`. The `data.labels` are the **groups** (categories); each\n`data.series` entry is a bar that repeats inside every group. **Color = series\nidentity:** one solid palette color per series (A=palette[0], B=palette[mid], extras\nspread), the same color in every group \u2014 never rainbow-within-a-series. A centered\nlegend names the series. Best read with \u2264 4 series.\n\nMinimal: `{ "type": "grouped", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"2023","values":[42,55] }, { "name":"2024","values":[52,48] }] } }`\n\n### `stacked` \u2014 stacked bar chart (segments stack within each category)\nDefault size `800 \xD7 450`. Each `data.labels` entry is a bar; each `data.series` entry\nis a **segment** stacked inside every bar, with a distinct palette color used\nconsistently across all bars. Inside each segment, two labels: the **name pinned to the\ntop**, the **value to the bottom** (contrast-aware text \u2014 dark on light fills, white on\ndark). Thin segments hide the name but keep the value. Only the top segment\'s outer edge\nis rounded; joins stay flush. A centered legend names the segments.\n\nMinimal: `{ "type": "stacked", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stacked100` \u2014 100% stacked bar (each bar normalized to 100%)\nSame as `stacked`, but every bar fills the full height and each segment shows its\n**share of that bar** as an integer percent. Percentages use largest-remainder\nrounding so every bar sums to **exactly 100** (never 99/101). Best for comparing\n*mix* across categories rather than absolute totals.\n\nMinimal: `{ "type": "stacked100", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stackedh` \u2014 horizontal stacked bar\nSame data model as `stacked`, transposed: bars run left\u2192right, one row per\n`data.labels` entry, segments stack horizontally. The last segment rounds its right\nedge; joins stay flush. Segment name + value sit centered in each segment (value-only\nwhen narrow); a centered legend names the segments. Good when category labels are long.\n\nMinimal: `{ "type": "stackedh", "data": { "labels": ["Eng","Sales"], "series": [{ "name":"Salaries","values":[120,90] }, { "name":"Tools","values":[40,25] }] } }`\n\n### `horizontal` \u2014 horizontal bar (single series)\nDefault size `800 \xD7 450`. One row per `data.labels` entry; each bar its own palette\ncolor by index (or its explicit `colors[i]`), value at the bar end, "Total: N" badge\ntop-right. Best when labels are long or there are many categories.\n\nMinimal: `{ "type": "horizontal", "data": { "labels": ["US","India"], "series": [{ "values": [820,610] }] } }`\n\n### `diverging` \u2014 diverging bar (around a zero line)\nDefault size `800 \xD7 450`. **Two modes, chosen by series count:**\n- **Two series** \u2192 opposing bars: **Series A forced up, Series B forced down** (both\n abs-valued), one solid color each (A=palette[0], B=palette[mid]); a centered legend\n names both. Great for sentiment (agree/disagree), inflow/outflow.\n- **One series** \u2192 classic **signed** diverging: each value plotted by its own sign\n (**positive up, negative down**), a single color, a single-name legend, and signed\n value labels (e.g. `-3` below the zero line).\n\nThe zero line is emphasized; down-bar labels sit below their bars.\n\nMinimal (two-series): `{ "type": "diverging", "data": { "labels": ["A","B"], "series": [{ "name":"Agree","values":[62,48] }, { "name":"Disagree","values":[38,52] }] } }`\n\nMinimal (signed): `{ "type": "diverging", "data": { "labels": ["Q1","Q2","Q3"], "series": [{ "name":"Net flow","values":[5,-3,8] }] } }`\n\n### `lollipop` \u2014 lollipop chart (single series)\nDefault size `800 \xD7 450`. One stem + dot per `data.labels` entry, each its own palette\ncolor by index (or explicit `colors[i]`), value above the dot, category below, "Total: N"\nbadge top-right. A lighter-weight alternative to `bar` when the bars would feel heavy.\n\nMinimal: `{ "type": "lollipop", "data": { "labels": ["A","B","C"], "series": [{ "values": [78,64,52] }] } }`\n\n### `line` \u2014 line chart (single or multi-series)\nDefault size `800 \xD7 450`. One line per `data.series` entry, each a palette color\n(or its own `color`), with points and a centered legend when there\'s more than one\nseries. "Nice" axis numbers; points span edge-to-edge.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `curve` | string | `"straight"` | line shape: `"straight"`, `"smooth"` (curved), `"stepped"` |\n| `area` | boolean | `false` | fill the region under each line (translucent) |\n| `showPoints` | boolean | `true` | dots at each data point |\n| `showValues` | boolean | `false` | numbers above each point (off by default \u2014 lines get crowded) |\n| series `color` | string | palette | per-line color override |\n\n**Type shortcuts:** `type: "smooth"`, `"area"`, `"stepped"` map to `line` with the\nmatching option set \u2014 `{type:"area"}` \u2261 `{type:"line", area:true}`. Also:\n- `type: "stackedArea"` \u2014 every series stacks cumulatively, filled bands + legend.\n- `type: "difference"` \u2014 exactly two series; shades the gap between them and labels\n the gap value at each point (green where series 1 leads, red where it trails).\n- `type: "slope"` \u2014 a slopegraph: each series\' first vs last value, connected by a\n straight line, with the end value + change labeled (green up / red down).\n\nMinimal: `{ "type": "line", "data": { "labels": ["Jan","Feb","Mar"], "series": [{ "name": "Users", "values": [120,190,170] }] } }`\n\n### `pie` / `donut` \u2014 proportion of a whole\nDefault size `640 \xD7 420`. One series; each label+value is a slice, colored from the\npalette (or explicit `colors`). Percentage labels sit inside slices \u2265 6%; a legend\n(label \xB7 value) runs down the right. `donut` adds a center hole with the **total** in\nthe middle.\n\nMinimal: `{ "type": "pie", "data": { "labels": ["A","B","C"], "series": [{ "values": [45,30,25] }] } }`\n\n### `pieofpie` \u2014 nested drill-down pies (pie-of-pie, pie-of-pie-of-pie, N-tier)\nDefault size `920 \xD7 460`. A row of pies where each pie\'s **first slice (the "bridge")**\nexplodes toward the next pie and breaks down into it, joined by two connector lines.\n2 pies = pie-of-pie, 3 = pie-of-pie-of-pie, N supported; each pie is 75% the size of the\none before. Donut by default. Each pie gets a **`name \xB7 value` legend beneath it**\n(honoring `valuePrefix`/`valueUnit`) so slices are identifiable; the slices themselves\nkeep their `%` labels.\n\nUses **`pies`** (not `data`): an array of `{ title?, labels[], values[], colors?, palette? }`.\nSlice index 0 of each pie is the bridge into the next pie.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `pies` | array | \u2014 (required) | the pies; pie[i] slice 0 drills into pie[i+1] |\n| `donut` | boolean | `true` | center hole on each pie (`false` = solid pies) |\n| `cascade` | boolean | `true` | child pies shade from the parent bridge hue; `false` = flat palette per pie |\n| `palette` | string | `"Clean Corporate"` | a **nested theme** (below) \u2014 drives the whole cascade |\n\n**Nested themes** (premium color cascades, set via `palette`):\n- *Inspired tiers* (hand-tuned per-pie triads): `Analogous Shift` (showcase \u2014 a spectrum\n walk), `Retro Editorial`, `Classic Triadic`, `Sorbet Pastel`.\n- *Generative* (children derived from the root hue): `Modern Corporate`, `Nordic Earth`,\n `Cyberpunk Glow`, `Sequential Rainbow`, plus classics `Clean Corporate`, `Pastel`,\n `Vibrant`, `Monochrome`. (Authoring new ones: `palette-core/AUTHORING-PALETTES.md`.)\n\nMinimal: `{ "type": "pieofpie", "palette": "Analogous Shift", "pies": [ { "labels": ["Enterprise","SMB","Other"], "values": [60,30,10] }, { "labels": ["US","EU","APAC"], "values": [35,15,10] } ] }`\n\n### `kpi` \u2014 single metric tile (the exec-snapshot building block)\nDefault size `340 \xD7 180`. A card: label + big value + colored delta pill + palette accent.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `label` | string | `""` | the metric name (top) |\n| `value` | number | \u2014 | the big number |\n| `valuePrefix` | string | `""` | e.g. `"$"` before the value |\n| `delta` | number | none | the change; renders a pill \u2014 \u25B2 if positive, \u25BC if negative |\n| `deltaUnit` | string | `"%"` | unit on the delta |\n| `deltaGoodWhen` | string | `"up"` | which direction is GOOD (green). Set `"down"` for lower-is-better metrics (churn, latency, cost) \u2014 the arrow still shows real direction, only the color flips |\n| `sparkline` | number[] | none | a minimalist trend line along the bottom (e.g. the last N periods) \u2014 no axes/labels, colored to match the delta (green good / red bad). Needs \u2265 2 points; **landscape tile only**. Omit it and the tile is byte-identical. |\n\nMinimal: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }`\n\nWith a sparkline: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4, "sparkline": [98,104,101,112,118,121,128] }`\n\n### `cards` \u2014 a row/grid of stat cards (the multi-KPI dashboard strip)\nA set of KPI tiles in one image \u2014 for an exec snapshot of several metrics at once.\nUses its own **`cards`** array (not `data`): each entry is one card, reusing the **same\nfields as `kpi`**. Each card\'s accent cycles the palette by index; the delta pill follows\nthe same good/bad coloring (\u25B2/\u25BC shows real direction, color shows good-or-bad). Layout is a\n**bounded computed grid**: by default a single horizontal strip, wrapping to a grid once there\nare more than 4 cards. Size auto-fits the grid unless you set `width`/`height`.\n\n| field | type | default | does |\n|---|---|---|---|\n| `cards` | array | \u2014 (required) | the tiles; each: `label`, `value`, `valuePrefix`, `valueUnit`, `delta`, `deltaUnit`, `deltaGoodWhen` (same meaning as `kpi`) |\n| `gridColumns` | number | min(cards, 4) | force the number of columns; cards wrap into rows |\n\nMinimal: `{ "type": "cards", "cards": [ { "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }, { "label": "Active users", "value": 8210, "delta": 3.1 }, { "label": "Churn", "value": 2.4, "valueUnit": "%", "delta": 0.6, "deltaGoodWhen": "down" } ] }`\n\n### `layers` \u2014 labeled box-stack / layer diagram\nA vertical stack of labeled blocks \u2014 a structure/architecture picture (e.g. a tech\nstack, a hierarchy of tiers), **not** a stacked bar (no values, no axis). Uses its own\n**`layers`** array (not `data`): each entry is `{ title, subtitle? }`, drawn top\u2192bottom\nas an equal-height rounded block. Each block fills a distinct palette color, cycling the\npalette; the title (and optional subtitle) sit centered with **contrast-aware text**\n(dark on light fills, white on dark). Inherits every universal field.\n\n| field | type | default | does |\n|---|---|---|---|\n| `layers` | array | \u2014 (required) | the blocks, top to bottom; each: `title`, optional `subtitle` |\n\nMinimal: `{ "type": "layers", "layers": [ { "title": "Application", "subtitle": "React + TypeScript" }, { "title": "API", "subtitle": "Node + Hono" }, { "title": "Engine", "subtitle": "render-core (pure SVG)" }, { "title": "Infra", "subtitle": "Railway + Docker" } ] }`\n\n### `progress` \u2014 labeled progress / bullet bars\nLabeled horizontal bars filling toward a target \u2014 for "how far along" readouts. Uses\nits own **`bars`** array: each `{ label, value, target?, valueUnit? }`. The track end IS\nthe target, so the fill = `value / target`; the label shows `value / target`. With **no\n`target`**, `value` is read as a percent (0\u2013100) and the track end is 100%. Each bar a\npalette color by index. Inherits every universal field.\n\n| field | type | default | does |\n|---|---|---|---|\n| `bars` | array | \u2014 (required) | the bars; each: `label`, `value`, optional `target`, optional `valueUnit` |\n\nMinimal: `{ "type": "progress", "bars": [ { "label": "Q1 revenue", "value": 82, "target": 100 }, { "label": "Signups", "value": 1240, "target": 2000 }, { "label": "Uptime", "value": 99.2, "valueUnit": "%" } ] }`\n\n### `waffle` \u2014 waffle / dot grid\nA 10\xD710 grid of cells showing part-of-whole \u2014 friendlier than a pie for "% complete" or\na simple mix. Uses its own **`parts`** array `{ label, value, color? }`: parts fill the\n100 cells proportionally (largest-remainder rounding keeps the count exact). If the parts\nsum to **less than 100**, the remainder are **empty track cells** \u2014 so **one part** is a\n"% filled" gauge, and **several parts** is categorical part-to-whole. A `label \xB7 value`\nlegend sits beside the grid.\n\n| field | type | default | does |\n|---|---|---|---|\n| `parts` | array | \u2014 (required) | the filled groups; each: `label`, `value` (cell count), optional `color` |\n\nMinimal: `{ "type": "waffle", "parts": [ { "label": "Enterprise", "value": 45 }, { "label": "SMB", "value": 30 }, { "label": "Other", "value": 15 } ] }`\n\n### `heatmap` \u2014 colored grid (rows \xD7 columns)\nA grid of cells colored by value (light \u2192 dark on a single palette hue) \u2014 for "intensity\nacross two dimensions" (e.g. activity by day \xD7 hour). Uses its own 2D shape: **`rows`**\n(row labels), **`columns`** (column labels), and **`values`** \u2014 a matrix where\n`values[row][col]` is the cell value. Cell value text is contrast-aware; `showValues:false`\nhides the numbers.\n\n| field | type | default | does |\n|---|---|---|---|\n| `rows` | string[] | \u2014 (required) | row labels (one per matrix row) |\n| `columns` | string[] | \u2014 (required) | column labels (one per matrix column) |\n| `values` | number[][] | \u2014 (required) | the matrix, `values[row][col]` |\n\nMinimal: `{ "type": "heatmap", "rows": ["Mon","Tue","Wed"], "columns": ["9a","12p","3p","6p"], "values": [[2,5,8,3],[1,4,9,6],[0,3,7,2]] }`\n\n### `funnel` \u2014 stages narrowing top\u2192bottom\nA conversion/drop-off funnel. Uses its own **`stages`** array `{ label, value }`: each\nstage is a centered band whose width is proportional to its value, tapering into the\nnext. Each band shows the label, the value, and its **% of the top stage**. Palette\ncolor per stage; band text is contrast-aware.\n\n| field | type | default | does |\n|---|---|---|---|\n| `stages` | array | \u2014 (required) | the stages top\u2192bottom; each: `label`, `value` |\n\nMinimal: `{ "type": "funnel", "stages": [ { "label": "Visitors", "value": 12000 }, { "label": "Signups", "value": 4200 }, { "label": "Trials", "value": 1800 }, { "label": "Paid", "value": 640 } ] }`\n\n### `pyramid` \u2014 hierarchy levels\nA triangle (apex on top) split into equal-height bands \u2014 for layered hierarchies\n(Maslow-style, org tiers, strategy levels). Uses its own **`levels`** array\n`{ title, value? }` (`label` is accepted as an alias for `title`). Each level a palette\ncolor, label centered with contrast-aware text; `value` is shown beside the label if set.\n\n| field | type | default | does |\n|---|---|---|---|\n| `levels` | array | \u2014 (required) | the levels, apex\u2192base; each: `title`, optional `value` |\n\nMinimal: `{ "type": "pyramid", "levels": [ { "title": "Vision" }, { "title": "Strategy" }, { "title": "Execution" }, { "title": "Operations" } ] }`\n\n### `quadrant` \u2014 2\xD72 matrix\nItems placed by two dimensions (effort/impact, reach/ease, etc.). Uses its own **`items`**\narray `{ label, x, y }` with `x`/`y` in **0\u20131** (x left\u2192right, y bottom\u2192top), plus\n**`xAxis`** and **`yAxis`** labels. A square plot is split by a crosshair into four\nquadrants; each item is a labeled dot (palette color by index).\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | the items; each: `label`, `x` (0\u20131), `y` (0\u20131) |\n| `xAxis` | string | `""` | horizontal axis label |\n| `yAxis` | string | `""` | vertical axis label |\n\nMinimal: `{ "type": "quadrant", "xAxis": "Effort", "yAxis": "Impact", "items": [ { "label": "Quick win", "x": 0.2, "y": 0.8 }, { "label": "Big bet", "x": 0.8, "y": 0.9 }, { "label": "Time sink", "x": 0.8, "y": 0.2 } ] }`\n\n### `timeline` \u2014 events along one line\nA linear timeline. Uses its own **`events`** array `{ date?, label }`: events sit evenly\nspaced along a horizontal line, the date by the line and the label **alternating\nabove/below** so they don\'t crowd. Keep labels short.\n\n| field | type | default | does |\n|---|---|---|---|\n| `events` | array | \u2014 (required) | the events in order; each: optional `date`, `label` |\n\nMinimal: `{ "type": "timeline", "events": [ { "date": "Q1", "label": "Launch" }, { "date": "Q2", "label": "Series A" }, { "date": "Q3", "label": "100k users" }, { "date": "Q4", "label": "Profitable" } ] }`\n\n### `venn` \u2014 2\u20133 overlapping sets\nOverlapping translucent circles for set relationships. Uses its own **`sets`** array\n`{ label, value }` (2 or 3 sets; fixed layout \u2014 2 side-by-side, 3 in a triangle) plus an\noptional **`overlap`** count (2-set), shown in the lens. Each circle is labeled with its\nvalue.\n\n| field | type | default | does |\n|---|---|---|---|\n| `sets` | array | \u2014 (required) | 2\u20133 sets; each: `label`, `value` |\n| `overlap` | number | \u2014 | (2 sets) the count shared by both, drawn in the overlap |\n\nMinimal: `{ "type": "venn", "sets": [ { "label": "Design", "value": 120 }, { "label": "Engineering", "value": 160 } ], "overlap": 40 }`\n\n### `matrix` \u2014 comparison / feature matrix\nA rows \xD7 columns table of \u2713/\u2717 (the pricing/feature-comparison look). Uses its own shape:\n**`columns`** (string[]) + **`rows`** `{ label, cells[] }` (one cell per column). Each cell is\na **boolean** or **status word** \u2192 a glyph (`true`/`"yes"` \u2713 green, `false`/`"no"` \u2717 grey,\n`"partial"` \u2022 amber, `""`/`"-"` dash), or any other string \u2192 rendered as **text** (e.g. a\nplan limit). Rows zebra-stripe for readability. `labelWidth` sets the row-label column width.\n\n| field | type | default | does |\n|---|---|---|---|\n| `columns` | string[] | \u2014 (required) | the column headers |\n| `rows` | array | \u2014 (required) | each: `label`, `cells[]` (boolean / status word / text, aligned to columns) |\n| `labelWidth` | number | `200` | px reserved for the row-label column |\n\nMinimal: `{ "type": "matrix", "columns": ["Free","Pro","Enterprise"], "rows": [ { "label": "SSO / SAML", "cells": [false,false,true] }, { "label": "API access", "cells": [false,true,true] }, { "label": "Priority support", "cells": [false,"partial",true] }, { "label": "Seats", "cells": ["1","10","Unlimited"] } ] }`\n\n### `checklist` \u2014 checklist / status list\nA vertical list of items each with a status glyph \u2014 for run-of-show / readiness lists. Uses\nits own **`items`** array `{ label, status }`: **`done`** \u2713 (green, label mutes), **`blocked`**\n\u2717 (red), **`partial`** \u2022 (amber), anything else (or `pending`) \u2192 an empty ring.\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | each: `label`, `status` (`done`/`pending`/`blocked`/`partial`) |\n\nMinimal: `{ "type": "checklist", "title": "Launch checklist", "items": [ { "label": "Domain transferred", "status": "done" }, { "label": "API deployed", "status": "done" }, { "label": "Load testing", "status": "pending" }, { "label": "Billing live", "status": "blocked" } ] }`\n\n### `iconarray` \u2014 icon array / pictogram\n`total` person icons with the first `filled` colored and the rest faint \u2014 a friendly\npart-of-whole ("7 of 10 teams onboarded"). `perRow` controls wrapping.\n\n| field | type | default | does |\n|---|---|---|---|\n| `total` | number | `10` | total icons |\n| `filled` | number | `0` | how many are filled (the rest faint) |\n| `perRow` | number | `min(total,10)` | icons per row before wrapping |\n\nMinimal: `{ "type": "iconarray", "title": "Teams onboarded (7/10)", "total": 10, "filled": 7 }`\n\n### `steps` \u2014 step / process row\nNumbered nodes left\u2192right joined by connector arrows \u2014 a **linear** process flow (not a\nbranching graph). Uses its own **`steps`** array `{ label, description? }`: each step is a\nnumbered palette circle with its label (and optional description) beneath. Positions are\ncomputed directly; no graph layout.\n\n| field | type | default | does |\n|---|---|---|---|\n| `steps` | array | \u2014 (required) | the steps in order; each: `label`, optional `description` |\n\nMinimal: `{ "type": "steps", "steps": [ { "label": "Sign up" }, { "label": "Connect data" }, { "label": "Build chart" }, { "label": "Share" } ] }`\n\n### `table` \u2014 data table (rows \xD7 columns)\nA plain tabular grid of text/values (the general tabular type; `matrix` is the \u2713/\u2717 variant).\nUses its own shape: **`columns`** (string[] header) + **`rows`** (an array of **cell arrays**).\nNumbers right-align and format with thousands separators; text left-aligns. Header rule +\nzebra rows.\n\n| field | type | default | does |\n|---|---|---|---|\n| `columns` | string[] | \u2014 (required) | the header cells |\n| `rows` | array | \u2014 (required) | each row is an array of cells (string or number), aligned to columns |\n\nMinimal: `{ "type": "table", "columns": ["Region","Q1","Q2","Q3"], "rows": [ ["North",420,510,480], ["South",310,290,350], ["East",530,560,600] ] }`\n\n### `gauge` \u2014 radial dial (single value)\nA 180\xB0 dial showing one value on a `min`..`max` scale \u2014 for "how full / how far" readouts. The\narc band fills to the value; the number sits in the center with `min`/`max` at the ends.\n\n| field | type | default | does |\n|---|---|---|---|\n| `value` | number | \u2014 (required) | the value to show |\n| `min` | number | `0` | scale minimum |\n| `max` | number | `100` | scale maximum |\n| `label` | string | `""` | caption under the value |\n| `valueUnit` | string | `""` | appended to the value |\n\nMinimal: `{ "type": "gauge", "label": "CPU load", "value": 72, "valueUnit": "%" }`\n\n### `bullet` \u2014 bullet graph\nA compact measure-vs-target gauge (Stephen Few style): a thin measure bar over grey\nqualitative bands, with a target tick \u2014 richer than `progress`. Uses its own **`bars`** array\n`{ label, value, target, max, bands }`, where `bands` are the threshold edges (e.g. `[150,225]`)\nthat shade the background into qualitative ranges.\n\n| field | type | default | does |\n|---|---|---|---|\n| `bars` | array | \u2014 (required) | each: `label`, `value`, optional `target` (tick), `max` (scale), `bands` (range edges) |\n\nMinimal: `{ "type": "bullet", "bars": [ { "label": "Revenue", "value": 275, "target": 250, "max": 300, "bands": [150,225] }, { "label": "Profit", "value": 82, "target": 100, "max": 120, "bands": [60,90] } ] }`\n\n### `calendar` \u2014 calendar heatmap (year grid)\nA GitHub-style contribution grid: weeks across, days down, each day a cell colored light\u2192dark by\nvalue on one palette hue. Uses **`days`** \u2014 a map of `"YYYY-MM-DD" \u2192 value` \u2014 plus **`year`**.\nWeekday placement is computed arithmetically (no clock), so it\'s fully deterministic.\n\n| field | type | default | does |\n|---|---|---|---|\n| `days` | object | \u2014 (required) | `"YYYY-MM-DD"` \u2192 number (the value for that day) |\n| `year` | number | `2025` | which year the grid covers (sets leap year + start weekday) |\n\nMinimal: `{ "type": "calendar", "title": "Activity", "year": 2025, "days": { "2025-01-06": 3, "2025-03-14": 8, "2025-07-21": 5 } }`\n\n### `leaderboard` \u2014 ranked rows\nA ranked list: each row is rank # + label + a bar (\u221D value) + the value, **auto-sorted\ndescending**. Uses its own **`items`** array `{ label, value }`.\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | each: `label`, `value` (sorted high\u2192low, numbered) |\n\nMinimal: `{ "type": "leaderboard", "title": "Top regions", "items": [ { "label": "North", "value": 530 }, { "label": "South", "value": 480 }, { "label": "East", "value": 610 }, { "label": "West", "value": 390 } ] }`\n\n### `callout` \u2014 hero stat + caption + annotation\nAn editorial single-stat: a big `value` (with `valuePrefix`/`valueUnit`), a `caption` line under\nit, and an optional `note` pill on the right. The reports/social cousin of `kpi`.\n| field | type | does |\n|---|---|---|\n| `value` | number | the hero number |\n| `caption` | string | the line under it |\n| `note` | string | optional annotation pill |\n\nMinimal: `{ "type": "callout", "value": 3.4, "valueUnit": "\xD7", "caption": "faster than last quarter", "note": "vs Q1" }`\n\n### `ring` \u2014 radial % toward a target\nA compact donut-arc badge showing `value / target` as a percent in the center.\n| field | type | does |\n|---|---|---|\n| `value` | number | current value |\n| `target` | number | the goal (default 100) |\n| `label` | string | optional caption below |\n\nMinimal: `{ "type": "ring", "value": 70, "target": 100, "label": "Goal" }`\n\n### `versus` \u2014 two options compared\nTwo mirrored columns with a VS badge between. Uses **`sides`** (exactly two): each `{ title, items[] }`,\neach item `{ label, value? }`.\n\nMinimal: `{ "type": "versus", "sides": [ { "title": "Plan A", "items": [ { "label": "Price", "value": 9 } ] }, { "title": "Plan B", "items": [ { "label": "Price", "value": 29 } ] } ] }`\n\n### `gantt` \u2014 tasks across a time row\nSchedule bars on a **self-contained** time layout (NOT the numeric axis). Uses **`tasks`**:\neach `{ label, start, end }` (positions on the timeline).\n\nMinimal: `{ "type": "gantt", "tasks": [ { "label": "Design", "start": 0, "end": 3 }, { "label": "Build", "start": 2, "end": 7 } ] }`\n\n### `waterfall` \u2014 running total, step by step\nFloating bars showing how a `start` becomes an end through signed deltas, with a final total bar.\nUses **`steps`** `{ label, value }` (value = the signed delta); up green, down red, total dark.\n**Each bar is labeled with its magnitude** (the signed delta above increases, below decreases;\nthe total above its bar) \u2014 on by default; set `showValues:false` to hide them (the bars +\nconnectors remain). Dashed connectors carry the running total bar-to-bar. **`label` is the step\nNAME only** \u2014 the engine renders the number for you, so don\'t embed the value in the label text.\n\nMinimal: `{ "type": "waterfall", "start": 0, "steps": [ { "label": "Q1", "value": 50 }, { "label": "Q2", "value": 30 }, { "label": "Q3", "value": -20 } ] }`\n\n### `swimlane` \u2014 lanes \xD7 phases roadmap\nA bounded grid: **`phases`** (column headers) \xD7 **`lanes`** (rows), each lane\'s `items` placed in a\n`phase` column. Distinct from a routed flowchart.\n\nMinimal: `{ "type": "swimlane", "phases": ["Q1","Q2","Q3"], "lanes": [ { "label": "Eng", "items": [ { "phase": 0, "label": "API" }, { "phase": 2, "label": "v2" } ] } ] }`\n\n### `tierlist` \u2014 ranked buckets of chips\nS/A/B-style rows: each tier a colored label box + its `items` as chips. Uses **`tiers`** `{ label, color?, items[] }`.\n\nMinimal: `{ "type": "tierlist", "tiers": [ { "label": "S", "items": ["Bar","Line"] }, { "label": "A", "items": ["Pie"] } ] }`\n\n### `swot` \u2014 four labeled 2\xD72 cells\nFour tinted cells, each a heading + a short bullet list (single-line items). Uses **`cells`** (up to 4)\n`{ title, items[] }`.\n\nMinimal: `{ "type": "swot", "cells": [ { "title": "Strengths", "items": ["Fast","Cheap"] }, { "title": "Weaknesses", "items": ["New brand"] }, { "title": "Opportunities", "items": ["AI demand"] }, { "title": "Threats", "items": ["Incumbents"] } ] }`\n\n### `dashboard` \u2014 tile many charts into one image\nThe composition layer: lays out **other charts** on a grid and rasterizes them in a single pass\n(one nested SVG \u2192 one PNG). Uses **`tiles`** `{ chart, span? }` \u2014 each `chart` is a **complete spec\nof any other type** (the same object you\'d render standalone), and `span` is `[colspan, rowspan]`\n(default `[1,1]`). Optional **`layout`** `{ cols?, gap?, pad?, tileWidth?, tileHeight? }` (cols\ndefault 3). Board-level `title`, `palette`, `font`, and `background` cascade to any tile that\ndoesn\'t set its own; per-tile watermarks are suppressed in favor of one board watermark. A wide\nchart can span two columns, a tall one two rows.\n\nMinimal: `{ "type": "dashboard", "layout": { "cols": 2 }, "tiles": [ { "chart": { "type": "kpi", "label": "Revenue", "value": 128400, "valuePrefix": "$", "delta": 12.4 } }, { "chart": { "type": "bar", "data": { "labels": ["A","B","C"], "series": [ { "name": "Sales", "values": [8,5,3] } ] } } } ] }`\n\n## What an agent can change (the levers)\n\n- **Fonts** \u2192 `fontFamily`, `fontSize`\n- **Colors** \u2192 `palette` (a named set) for the whole chart, or `data.series[].colors` for exact\n per-item control. The info-design types take **explicit per-element color** the same way: add a\n `color` to any element object \u2014 `cards[].color`, `layers[].color`, `bars[].color` (progress/\n bullet), `stages[].color`, `levels[].color`, `items[].color` (quadrant/leaderboard),\n `events[].color`, `sets[].color`, `steps[].color`, `parts[].color` (waffle). The single-accent /\n gradient types take a **top-level `color`** \u2014 the `gauge` arc, `iconarray` filled icons, and the\n `heatmap`/`calendar` ramp hue. Omit it and color comes from the palette (contrast-aware text\n adapts to whatever fill you choose). (`matrix`/`checklist` glyphs stay semantic \u2014 no override.)\n- **Background** \u2192 `background` (any hex, or `"transparent"`)\n- **Size / aspect** \u2192 `width`, `height`\n- **Branding** \u2192 `watermark` (off for the sites, on for free-tier API output)\n\n## Rules for the engine (and any agent generating specs)\n\n- **Deterministic:** the same spec always produces the exact same SVG bytes. No\n randomness, no time. Safe to cache and snapshot-test.\n- **Defaults are good:** never require a field that has a sensible default. Emit the\n smallest spec that expresses the intent; the engine makes it beautiful.\n- **Unknown `type` is an error** \u2014 only documented types render.\n\n## Roadmap\nMore chart types (line, area, stacked, pie, \u2026) register in `renderSpec`; each inherits\nevery universal field above and gets its own row here. This document becomes a formal\nJSON Schema when the MCP server is built, so the `render_chart` tool can validate specs\nand an LLM can read the field contract directly.\n' : readFileSync(join(here, "../../packages/render-core/SPEC.md"), "utf8");
2867
+ var CHART_SPEC_MD = true ? '# ChartSpec \u2014 the render-core contract (the API/MCP input)\n\nOne JSON object (a **spec**) goes in; one **SVG string** comes out, via\n`renderSpec(spec)`. This document is the contract an agent (or the MCP tool) follows\nto produce a chart. It is written agent-first: **a spec with only `type` + `data`\nrenders a complete, good-looking chart** \u2014 every other field is an optional override.\n\n```js\nimport { renderSpec } from \'./render-core.mjs\';\nconst svg = renderSpec({ type: \'bar\', data: { labels: [\'A\',\'B\',\'C\'], series: [{ values: [10, 20, 15] }] } });\n```\n\n## When to use this \u2014 and when to fall back\n\n**Prefer `render_chart` for any supported type** (see "Chart types" below) over\nhand-writing plotting code (matplotlib, plotly, chart.js). It\'s faster, deterministic,\nand good-looking with just `{type, data}`. Pass `outputPath` when the user wants the\nfile saved to disk.\n\n**Fall back to your own plotting only for what this engine does not do yet:**\n- **Chart types not listed below** \u2014 scatter, bubble, heatmap, treemap, radar, sankey,\n geographic maps, candlestick, gantt.\n- **Reference / target / threshold lines, log scales, secondary (dual) axes, and point\n annotations / callouts** \u2014 on the roadmap, not available today.\n- **Interactivity, animation, hover tooltips** \u2014 output is a static SVG/PNG.\n\n## Universal fields (every chart type honors these)\n\n| field | type | default | what it does |\n|---|---|---|---|\n| `type` | string | \u2014 (required) | which chart: `"bar"`, `"line"`, `"kpi"` (more coming) |\n| `title` | string | `""` | heading drawn at the top |\n| `width` | number | per type | SVG width in px |\n| `height` | number | per type | SVG height in px |\n| `preset` / `ratio` | string | \u2014 | aspect-ratio preset \u2192 sets width/height (explicit `width`/`height` win). `Share Card` 1.91:1 (1200\xD7630, link/OG cards), `Wide` 16:9 (1280\xD7720), `Square` 1:1 (1080\xD71080), `Portrait` 4:5 (1080\xD71350), `Tall` 9:16 (1080\xD71920), `Classic` 4:3 (1200\xD7900). `ratio` also accepts the bare ratio string (`"16:9"`, `"9:16"`, \u2026) |\n| `background` | string | `"#ffffff"` | any hex color, or `"transparent"` for no fill (overlays/exports) |\n| `palette` | string | `"Clean Corporate"` | named palette: `Clean Corporate`, `Pastel`, `Vibrant`, `Monochrome`, `Cyberpunk`, `Analogous Shift`. Pick **`Monochrome`** when the chart will be **printed in black & white / on a laser printer** \u2014 it\'s a single-hue grey ramp that stays legible without color (other palettes can render as indistinct greys when printed). |\n| `font` | string | `"Inter"` | a named font: `Inter`, `System`, `Serif`, `Mono`, `Rounded`, `Condensed` |\n| `fontFamily` | string | \u2014 | a raw CSS font stack (overrides `font`; full control / future custom fonts) |\n| `fontSize` | number | `13` | base text size; other text scales from it |\n| `textColor` | string | auto | force a color for the **neutral** text (title, axis, category, value, legend, pie slice labels). Omit it and the engine auto-picks black/white for contrast. Semantic colors (green \u25B2 / red \u25BC deltas & gaps) stay meaningful. |\n| `bold` | boolean | `false` | thicken every text element |\n| `fontWeight` | string | \u2014 | exact weight for all text (`"400"`\u2013`"900"` or `"bold"`); overrides `bold` |\n| `valueUnit` | string | `""` | appended to values, e.g. `"$"`, `"%"`, `"ms"` |\n| `showValues` | boolean | `true`* | draw the numeric labels. **Only some types honor it** \u2014 the numeric charts (bar/grouped/stacked/horizontal/lollipop/diverging/pie/donut), the line family, `heatmap`, and `waterfall`. On every other type it is **ignored** (they show or omit values inherently). *Default is `true` except the line family (`false`, to avoid clutter). Call `describe_type` for a type\'s `honorsToggles`. |\n| `watermark` | boolean | `true` | tasteful "slickfast.com" mark. **Set `false` for the chart sites / paid output.** |\n\n**Readability is automatic:** text colors are chosen from the background\'s luminance\n(via palette-core), so labels stay legible on light *or* dark backgrounds.\n\n## Data shape\n\n```json\n"data": {\n "labels": ["North", "South", "East"],\n "series": [\n { "name": "Revenue", "values": [420, 310, 530], "colors": ["#2563eb", "#7c3aed", "#059669"] }\n ]\n}\n```\n\n- `labels` \u2014 the categories (x-axis).\n- `series[].values` \u2014 the numbers, aligned to `labels`.\n- `series[].colors` \u2014 **optional** explicit per-item colors. If omitted, colors come\n from `palette`. (Used when a user has customized individual bars.)\n- `series[].name` \u2014 optional label for the series.\n\n## Chart types\n\n### `bar` \u2014 vertical bar chart\nDefault size `800 \xD7 450`. Uses `data.series[0]`. Each bar a palette color (or its\nexplicit `colors[i]`), top-rounded, with value + category labels and "nice" axis numbers.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `showTotal` | boolean | `true` | "Total: N" badge, top-right |\n\nMinimal: `{ "type": "bar", "data": { "labels": ["A","B"], "series": [{ "values": [10,20] }] } }`\n\n### `grouped` \u2014 clustered bar chart (one bar per series in each category)\nDefault size `800 \xD7 450`. The `data.labels` are the **groups** (categories); each\n`data.series` entry is a bar that repeats inside every group. **Color = series\nidentity:** one solid palette color per series (A=palette[0], B=palette[mid], extras\nspread), the same color in every group \u2014 never rainbow-within-a-series. A centered\nlegend names the series. Best read with \u2264 4 series.\n\nMinimal: `{ "type": "grouped", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"2023","values":[42,55] }, { "name":"2024","values":[52,48] }] } }`\n\n### `stacked` \u2014 stacked bar chart (segments stack within each category)\nDefault size `800 \xD7 450`. Each `data.labels` entry is a bar; each `data.series` entry\nis a **segment** stacked inside every bar, with a distinct palette color used\nconsistently across all bars. Inside each segment, two labels: the **name pinned to the\ntop**, the **value to the bottom** (contrast-aware text \u2014 dark on light fills, white on\ndark). Thin segments hide the name but keep the value. Only the top segment\'s outer edge\nis rounded; joins stay flush. A centered legend names the segments.\n\nMinimal: `{ "type": "stacked", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stacked100` \u2014 100% stacked bar (each bar normalized to 100%)\nSame as `stacked`, but every bar fills the full height and each segment shows its\n**share of that bar** as an integer percent. Percentages use largest-remainder\nrounding so every bar sums to **exactly 100** (never 99/101). Best for comparing\n*mix* across categories rather than absolute totals.\n\nMinimal: `{ "type": "stacked100", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stackedh` \u2014 horizontal stacked bar\nSame data model as `stacked`, transposed: bars run left\u2192right, one row per\n`data.labels` entry, segments stack horizontally. The last segment rounds its right\nedge; joins stay flush. Segment name + value sit centered in each segment (value-only\nwhen narrow); a centered legend names the segments. Good when category labels are long.\n\nMinimal: `{ "type": "stackedh", "data": { "labels": ["Eng","Sales"], "series": [{ "name":"Salaries","values":[120,90] }, { "name":"Tools","values":[40,25] }] } }`\n\n### `horizontal` \u2014 horizontal bar (single series)\nDefault size `800 \xD7 450`. One row per `data.labels` entry; each bar its own palette\ncolor by index (or its explicit `colors[i]`), value at the bar end, "Total: N" badge\ntop-right. Best when labels are long or there are many categories.\n\nMinimal: `{ "type": "horizontal", "data": { "labels": ["US","India"], "series": [{ "values": [820,610] }] } }`\n\n### `diverging` \u2014 diverging bar (around a zero line)\nDefault size `800 \xD7 450`. **Two modes, chosen by series count:**\n- **Two series** \u2192 opposing bars: **Series A forced up, Series B forced down** (both\n abs-valued), one solid color each (A=palette[0], B=palette[mid]); a centered legend\n names both. Great for sentiment (agree/disagree), inflow/outflow.\n- **One series** \u2192 classic **signed** diverging: each value plotted by its own sign\n (**positive up, negative down**), a single color, a single-name legend, and signed\n value labels (e.g. `-3` below the zero line).\n\nThe zero line is emphasized; down-bar labels sit below their bars.\n\nMinimal (two-series): `{ "type": "diverging", "data": { "labels": ["A","B"], "series": [{ "name":"Agree","values":[62,48] }, { "name":"Disagree","values":[38,52] }] } }`\n\nMinimal (signed): `{ "type": "diverging", "data": { "labels": ["Q1","Q2","Q3"], "series": [{ "name":"Net flow","values":[5,-3,8] }] } }`\n\n### `lollipop` \u2014 lollipop chart (single series)\nDefault size `800 \xD7 450`. One stem + dot per `data.labels` entry, each its own palette\ncolor by index (or explicit `colors[i]`), value above the dot, category below, "Total: N"\nbadge top-right. A lighter-weight alternative to `bar` when the bars would feel heavy.\n\nMinimal: `{ "type": "lollipop", "data": { "labels": ["A","B","C"], "series": [{ "values": [78,64,52] }] } }`\n\n### `line` \u2014 line chart (single or multi-series)\nDefault size `800 \xD7 450`. One line per `data.series` entry, each a palette color\n(or its own `color`), with points and a centered legend when there\'s more than one\nseries. "Nice" axis numbers; points span edge-to-edge.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `curve` | string | `"straight"` | line shape: `"straight"`, `"smooth"` (curved), `"stepped"` |\n| `area` | boolean | `false` | fill the region under each line (translucent) |\n| `showPoints` | boolean | `true` | dots at each data point |\n| `showValues` | boolean | `false` | numbers above each point (off by default \u2014 lines get crowded) |\n| series `color` | string | palette | per-line color override |\n\n**Type shortcuts:** `type: "smooth"`, `"area"`, `"stepped"` map to `line` with the\nmatching option set \u2014 `{type:"area"}` \u2261 `{type:"line", area:true}`. Also:\n- `type: "stackedArea"` \u2014 every series stacks cumulatively, filled bands + legend.\n- `type: "difference"` \u2014 exactly two series; shades the gap between them and labels\n the gap value at each point (green where series 1 leads, red where it trails).\n- `type: "slope"` \u2014 a slopegraph: each series\' first vs last value, connected by a\n straight line, with the end value + change labeled (green up / red down).\n\nMinimal: `{ "type": "line", "data": { "labels": ["Jan","Feb","Mar"], "series": [{ "name": "Users", "values": [120,190,170] }] } }`\n\n### `pie` / `donut` \u2014 proportion of a whole\nDefault size `640 \xD7 420`. One series; each label+value is a slice, colored from the\npalette (or explicit `colors`). Percentage labels sit inside slices \u2265 6%; a legend\n(label \xB7 value) runs down the right. `donut` adds a center hole with the **total** in\nthe middle.\n\nMinimal: `{ "type": "pie", "data": { "labels": ["A","B","C"], "series": [{ "values": [45,30,25] }] } }`\n\n### `pieofpie` \u2014 nested drill-down pies (pie-of-pie, pie-of-pie-of-pie, N-tier)\nDefault size `920 \xD7 460`. A row of pies where each pie\'s **first slice (the "bridge")**\nexplodes toward the next pie and breaks down into it, joined by two connector lines.\n2 pies = pie-of-pie, 3 = pie-of-pie-of-pie, N supported; each pie is 75% the size of the\none before. Donut by default. Each pie gets a **`name \xB7 value` legend beneath it**\n(honoring `valuePrefix`/`valueUnit`) so slices are identifiable; the slices themselves\nkeep their `%` labels.\n\nUses **`pies`** (not `data`): an array of `{ title?, labels[], values[], colors?, palette? }`.\nSlice index 0 of each pie is the bridge into the next pie.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `pies` | array | \u2014 (required) | the pies; pie[i] slice 0 drills into pie[i+1] |\n| `donut` | boolean | `true` | center hole on each pie (`false` = solid pies) |\n| `cascade` | boolean | `true` | child pies shade from the parent bridge hue; `false` = flat palette per pie |\n| `palette` | string | `"Clean Corporate"` | a **nested theme** (below) \u2014 drives the whole cascade |\n\n**Nested themes** (premium color cascades, set via `palette`):\n- *Inspired tiers* (hand-tuned per-pie triads): `Analogous Shift` (showcase \u2014 a spectrum\n walk), `Retro Editorial`, `Classic Triadic`, `Sorbet Pastel`.\n- *Generative* (children derived from the root hue): `Modern Corporate`, `Nordic Earth`,\n `Cyberpunk Glow`, `Sequential Rainbow`, plus classics `Clean Corporate`, `Pastel`,\n `Vibrant`, `Monochrome`. (Authoring new ones: `palette-core/AUTHORING-PALETTES.md`.)\n\nMinimal: `{ "type": "pieofpie", "palette": "Analogous Shift", "pies": [ { "labels": ["Enterprise","SMB","Other"], "values": [60,30,10] }, { "labels": ["US","EU","APAC"], "values": [35,15,10] } ] }`\n\n### `kpi` \u2014 single metric tile (the exec-snapshot building block)\nDefault size `340 \xD7 180`. A card: label + big value + colored delta pill + palette accent.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `label` | string | `""` | the metric name (top) |\n| `value` | number | \u2014 | the big number |\n| `valuePrefix` | string | `""` | e.g. `"$"` before the value |\n| `delta` | number | none | the change; renders a pill \u2014 \u25B2 if positive, \u25BC if negative |\n| `deltaUnit` | string | `"%"` | unit on the delta |\n| `deltaGoodWhen` | string | `"up"` | which direction is GOOD (green). Set `"down"` for lower-is-better metrics (churn, latency, cost) \u2014 the arrow still shows real direction, only the color flips |\n| `sparkline` | number[] | none | a minimalist trend line along the bottom (e.g. the last N periods) \u2014 no axes/labels, colored to match the delta (green good / red bad). Needs \u2265 2 points; **landscape tile only**. Omit it and the tile is byte-identical. |\n\nMinimal: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }`\n\nWith a sparkline: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4, "sparkline": [98,104,101,112,118,121,128] }`\n\n### `cards` \u2014 a row/grid of stat cards (the multi-KPI dashboard strip)\nA set of KPI tiles in one image \u2014 for an exec snapshot of several metrics at once.\nUses its own **`cards`** array (not `data`): each entry is one card, reusing the **same\nfields as `kpi`**. Each card\'s accent cycles the palette by index; the delta pill follows\nthe same good/bad coloring (\u25B2/\u25BC shows real direction, color shows good-or-bad). Layout is a\n**bounded computed grid**: by default a single horizontal strip, wrapping to a grid once there\nare more than 4 cards. Size auto-fits the grid unless you set `width`/`height`.\n\n| field | type | default | does |\n|---|---|---|---|\n| `cards` | array | \u2014 (required) | the tiles; each: `label`, `value`, `valuePrefix`, `valueUnit`, `delta`, `deltaUnit`, `deltaGoodWhen` (same meaning as `kpi`) |\n| `gridColumns` | number | min(cards, 4) | force the number of columns; cards wrap into rows |\n\nMinimal: `{ "type": "cards", "cards": [ { "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }, { "label": "Active users", "value": 8210, "delta": 3.1 }, { "label": "Churn", "value": 2.4, "valueUnit": "%", "delta": 0.6, "deltaGoodWhen": "down" } ] }`\n\n### `layers` \u2014 labeled box-stack / layer diagram\nA vertical stack of labeled blocks \u2014 a structure/architecture picture (e.g. a tech\nstack, a hierarchy of tiers), **not** a stacked bar (no values, no axis). Uses its own\n**`layers`** array (not `data`): each entry is `{ title, subtitle? }`, drawn top\u2192bottom\nas an equal-height rounded block. Each block fills a distinct palette color, cycling the\npalette; the title (and optional subtitle) sit centered with **contrast-aware text**\n(dark on light fills, white on dark). Inherits every universal field.\n\n| field | type | default | does |\n|---|---|---|---|\n| `layers` | array | \u2014 (required) | the blocks, top to bottom; each: `title`, optional `subtitle` |\n\nMinimal: `{ "type": "layers", "layers": [ { "title": "Application", "subtitle": "React + TypeScript" }, { "title": "API", "subtitle": "Node + Hono" }, { "title": "Engine", "subtitle": "render-core (pure SVG)" }, { "title": "Infra", "subtitle": "Railway + Docker" } ] }`\n\n### `progress` \u2014 labeled progress / bullet bars\nLabeled horizontal bars filling toward a target \u2014 for "how far along" readouts. Uses\nits own **`bars`** array: each `{ label, value, target?, valueUnit? }`. The track end IS\nthe target, so the fill = `value / target`; the label shows `value / target`. With **no\n`target`**, `value` is read as a percent (0\u2013100) and the track end is 100%. Each bar a\npalette color by index. Inherits every universal field.\n\n| field | type | default | does |\n|---|---|---|---|\n| `bars` | array | \u2014 (required) | the bars; each: `label`, `value`, optional `target`, optional `valueUnit` |\n\nMinimal: `{ "type": "progress", "bars": [ { "label": "Q1 revenue", "value": 82, "target": 100 }, { "label": "Signups", "value": 1240, "target": 2000 }, { "label": "Uptime", "value": 99.2, "valueUnit": "%" } ] }`\n\n### `waffle` \u2014 waffle / dot grid\nA 10\xD710 grid of cells showing part-of-whole \u2014 friendlier than a pie for "% complete" or\na simple mix. Uses its own **`parts`** array `{ label, value, color? }`: parts fill the\n100 cells proportionally (largest-remainder rounding keeps the count exact). If the parts\nsum to **less than 100**, the remainder are **empty track cells** \u2014 so **one part** is a\n"% filled" gauge, and **several parts** is categorical part-to-whole. A `label \xB7 value`\nlegend sits beside the grid.\n\n| field | type | default | does |\n|---|---|---|---|\n| `parts` | array | \u2014 (required) | the filled groups; each: `label`, `value` (cell count), optional `color` |\n\nMinimal: `{ "type": "waffle", "parts": [ { "label": "Enterprise", "value": 45 }, { "label": "SMB", "value": 30 }, { "label": "Other", "value": 15 } ] }`\n\n### `heatmap` \u2014 colored grid (rows \xD7 columns)\nA grid of cells colored by value (light \u2192 dark on a single palette hue) \u2014 for "intensity\nacross two dimensions" (e.g. activity by day \xD7 hour). Uses its own 2D shape: **`rows`**\n(row labels), **`columns`** (column labels), and **`values`** \u2014 a matrix where\n`values[row][col]` is the cell value. Cell value text is contrast-aware; `showValues:false`\nhides the numbers.\n\n| field | type | default | does |\n|---|---|---|---|\n| `rows` | string[] | \u2014 (required) | row labels (one per matrix row) |\n| `columns` | string[] | \u2014 (required) | column labels (one per matrix column) |\n| `values` | number[][] | \u2014 (required) | the matrix, `values[row][col]` |\n\nMinimal: `{ "type": "heatmap", "rows": ["Mon","Tue","Wed"], "columns": ["9a","12p","3p","6p"], "values": [[2,5,8,3],[1,4,9,6],[0,3,7,2]] }`\n\n### `funnel` \u2014 stages narrowing top\u2192bottom\nA conversion/drop-off funnel. Uses its own **`stages`** array `{ label, value }`: each\nstage is a centered band whose width is proportional to its value, tapering into the\nnext. Each band shows the label, the value, and its **% of the top stage**. Palette\ncolor per stage; band text is contrast-aware.\n\n| field | type | default | does |\n|---|---|---|---|\n| `stages` | array | \u2014 (required) | the stages top\u2192bottom; each: `label`, `value` |\n\nMinimal: `{ "type": "funnel", "stages": [ { "label": "Visitors", "value": 12000 }, { "label": "Signups", "value": 4200 }, { "label": "Trials", "value": 1800 }, { "label": "Paid", "value": 640 } ] }`\n\n### `pyramid` \u2014 hierarchy levels\nA triangle (apex on top) split into equal-height bands \u2014 for layered hierarchies\n(Maslow-style, org tiers, strategy levels). Uses its own **`levels`** array\n`{ title, value? }` (`label` is accepted as an alias for `title`). Each level a palette\ncolor, label centered with contrast-aware text; `value` is shown beside the label if set.\n\n| field | type | default | does |\n|---|---|---|---|\n| `levels` | array | \u2014 (required) | the levels, apex\u2192base; each: `title`, optional `value` |\n\nMinimal: `{ "type": "pyramid", "levels": [ { "title": "Vision" }, { "title": "Strategy" }, { "title": "Execution" }, { "title": "Operations" } ] }`\n\n### `quadrant` \u2014 2\xD72 matrix\nItems placed by two dimensions (effort/impact, reach/ease, etc.). Uses its own **`items`**\narray `{ label, x, y }` with `x`/`y` in **0\u20131** (x left\u2192right, y bottom\u2192top), plus\n**`xAxis`** and **`yAxis`** labels. A square plot is split by a crosshair into four\nquadrants; each item is a labeled dot (palette color by index).\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | the items; each: `label`, `x` (0\u20131), `y` (0\u20131) |\n| `xAxis` | string | `""` | horizontal axis label |\n| `yAxis` | string | `""` | vertical axis label |\n\nMinimal: `{ "type": "quadrant", "xAxis": "Effort", "yAxis": "Impact", "items": [ { "label": "Quick win", "x": 0.2, "y": 0.8 }, { "label": "Big bet", "x": 0.8, "y": 0.9 }, { "label": "Time sink", "x": 0.8, "y": 0.2 } ] }`\n\n### `timeline` \u2014 events along one line\nA linear timeline. Uses its own **`events`** array `{ date?, label }`: events sit evenly\nspaced along a horizontal line, the date by the line and the label **alternating\nabove/below** so they don\'t crowd. Keep labels short.\n\n| field | type | default | does |\n|---|---|---|---|\n| `events` | array | \u2014 (required) | the events in order; each: optional `date`, `label` |\n\nMinimal: `{ "type": "timeline", "events": [ { "date": "Q1", "label": "Launch" }, { "date": "Q2", "label": "Series A" }, { "date": "Q3", "label": "100k users" }, { "date": "Q4", "label": "Profitable" } ] }`\n\n### `venn` \u2014 2\u20133 overlapping sets\nOverlapping translucent circles for set relationships. Uses its own **`sets`** array\n`{ label, value }` (2 or 3 sets; fixed layout \u2014 2 side-by-side, 3 in a triangle) plus an\noptional **`overlap`** count (2-set), shown in the lens. Each circle is labeled with its\nvalue.\n\n| field | type | default | does |\n|---|---|---|---|\n| `sets` | array | \u2014 (required) | 2\u20133 sets; each: `label`, `value` |\n| `overlap` | number | \u2014 | (2 sets) the count shared by both, drawn in the overlap |\n\nMinimal: `{ "type": "venn", "sets": [ { "label": "Design", "value": 120 }, { "label": "Engineering", "value": 160 } ], "overlap": 40 }`\n\n### `matrix` \u2014 comparison / feature matrix\nA rows \xD7 columns table of \u2713/\u2717 (the pricing/feature-comparison look). Uses its own shape:\n**`columns`** (string[]) + **`rows`** `{ label, cells[] }` (one cell per column). Each cell is\na **boolean** or **status word** \u2192 a glyph (`true`/`"yes"` \u2713 green, `false`/`"no"` \u2717 grey,\n`"partial"` \u2022 amber, `""`/`"-"` dash), or any other string \u2192 rendered as **text** (e.g. a\nplan limit). Rows zebra-stripe for readability. `labelWidth` sets the row-label column width.\n\n| field | type | default | does |\n|---|---|---|---|\n| `columns` | string[] | \u2014 (required) | the column headers |\n| `rows` | array | \u2014 (required) | each: `label`, `cells[]` (boolean / status word / text, aligned to columns) |\n| `labelWidth` | number | `200` | px reserved for the row-label column |\n\nMinimal: `{ "type": "matrix", "columns": ["Free","Pro","Enterprise"], "rows": [ { "label": "SSO / SAML", "cells": [false,false,true] }, { "label": "API access", "cells": [false,true,true] }, { "label": "Priority support", "cells": [false,"partial",true] }, { "label": "Seats", "cells": ["1","10","Unlimited"] } ] }`\n\n### `checklist` \u2014 checklist / status list\nA vertical list of items each with a status glyph \u2014 for run-of-show / readiness lists. Uses\nits own **`items`** array `{ label, status }`: **`done`** \u2713 (green, label mutes), **`blocked`**\n\u2717 (red), **`partial`** \u2022 (amber), anything else (or `pending`) \u2192 an empty ring.\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | each: `label`, `status` (`done`/`pending`/`blocked`/`partial`) |\n\nMinimal: `{ "type": "checklist", "title": "Launch checklist", "items": [ { "label": "Domain transferred", "status": "done" }, { "label": "API deployed", "status": "done" }, { "label": "Load testing", "status": "pending" }, { "label": "Billing live", "status": "blocked" } ] }`\n\n### `iconarray` \u2014 icon array / pictogram\n`total` person icons with the first `filled` colored and the rest faint \u2014 a friendly\npart-of-whole ("7 of 10 teams onboarded"). `perRow` controls wrapping.\n\n| field | type | default | does |\n|---|---|---|---|\n| `total` | number | `10` | total icons |\n| `filled` | number | `0` | how many are filled (the rest faint) |\n| `perRow` | number | `min(total,10)` | icons per row before wrapping |\n\nMinimal: `{ "type": "iconarray", "title": "Teams onboarded (7/10)", "total": 10, "filled": 7 }`\n\n### `steps` \u2014 step / process row\nNumbered nodes left\u2192right joined by connector arrows \u2014 a **linear** process flow (not a\nbranching graph). Uses its own **`steps`** array `{ label, description? }`: each step is a\nnumbered palette circle with its label (and optional description) beneath. Positions are\ncomputed directly; no graph layout.\n\n| field | type | default | does |\n|---|---|---|---|\n| `steps` | array | \u2014 (required) | the steps in order; each: `label`, optional `description` |\n\nMinimal: `{ "type": "steps", "steps": [ { "label": "Sign up" }, { "label": "Connect data" }, { "label": "Build chart" }, { "label": "Share" } ] }`\n\n### `table` \u2014 data table (rows \xD7 columns)\nA plain tabular grid of text/values (the general tabular type; `matrix` is the \u2713/\u2717 variant).\nUses its own shape: **`columns`** (string[] header) + **`rows`** (an array of **cell arrays**).\nNumbers right-align and format with thousands separators; text left-aligns. Header rule +\nzebra rows.\n\n| field | type | default | does |\n|---|---|---|---|\n| `columns` | string[] | \u2014 (required) | the header cells |\n| `rows` | array | \u2014 (required) | each row is an array of cells (string or number), aligned to columns |\n\nMinimal: `{ "type": "table", "columns": ["Region","Q1","Q2","Q3"], "rows": [ ["North",420,510,480], ["South",310,290,350], ["East",530,560,600] ] }`\n\n### `gauge` \u2014 radial dial (single value)\nA 180\xB0 dial showing one value on a `min`..`max` scale \u2014 for "how full / how far" readouts. The\narc band fills to the value; the number sits in the center with `min`/`max` at the ends.\n\n| field | type | default | does |\n|---|---|---|---|\n| `value` | number | \u2014 (required) | the value to show |\n| `min` | number | `0` | scale minimum |\n| `max` | number | `100` | scale maximum |\n| `label` | string | `""` | caption under the value |\n| `valueUnit` | string | `""` | appended to the value |\n\nMinimal: `{ "type": "gauge", "label": "CPU load", "value": 72, "valueUnit": "%" }`\n\n### `bullet` \u2014 bullet graph\nA compact measure-vs-target gauge (Stephen Few style): a thin measure bar over grey\nqualitative bands, with a target tick \u2014 richer than `progress`. Uses its own **`bars`** array\n`{ label, value, target, max, bands }`, where `bands` are the threshold edges (e.g. `[150,225]`)\nthat shade the background into qualitative ranges.\n\n| field | type | default | does |\n|---|---|---|---|\n| `bars` | array | \u2014 (required) | each: `label`, `value`, optional `target` (tick), `max` (scale), `bands` (range edges) |\n\nMinimal: `{ "type": "bullet", "bars": [ { "label": "Revenue", "value": 275, "target": 250, "max": 300, "bands": [150,225] }, { "label": "Profit", "value": 82, "target": 100, "max": 120, "bands": [60,90] } ] }`\n\n### `calendar` \u2014 calendar heatmap (year grid)\nA GitHub-style contribution grid: weeks across, days down, each day a cell colored light\u2192dark by\nvalue on one palette hue. Uses **`days`** \u2014 a map of `"YYYY-MM-DD" \u2192 value` \u2014 plus **`year`**.\nWeekday placement is computed arithmetically (no clock), so it\'s fully deterministic.\n\n| field | type | default | does |\n|---|---|---|---|\n| `days` | object | \u2014 (required) | `"YYYY-MM-DD"` \u2192 number (the value for that day) |\n| `year` | number | `2025` | which year the grid covers (sets leap year + start weekday) |\n\nMinimal: `{ "type": "calendar", "title": "Activity", "year": 2025, "days": { "2025-01-06": 3, "2025-03-14": 8, "2025-07-21": 5 } }`\n\n### `leaderboard` \u2014 ranked rows\nA ranked list: each row is rank # + label + a bar (\u221D value) + the value, **auto-sorted\ndescending**. Uses its own **`items`** array `{ label, value }`.\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | each: `label`, `value` (sorted high\u2192low, numbered) |\n\nMinimal: `{ "type": "leaderboard", "title": "Top regions", "items": [ { "label": "North", "value": 530 }, { "label": "South", "value": 480 }, { "label": "East", "value": 610 }, { "label": "West", "value": 390 } ] }`\n\n### `callout` \u2014 hero stat + caption + annotation\nAn editorial single-stat: a big `value` (with `valuePrefix`/`valueUnit`), a `caption` line under\nit, and an optional `note` pill on the right. The reports/social cousin of `kpi`.\n| field | type | does |\n|---|---|---|\n| `value` | number | the hero number |\n| `caption` | string | the line under it |\n| `note` | string | optional annotation pill |\n\nMinimal: `{ "type": "callout", "value": 3.4, "valueUnit": "\xD7", "caption": "faster than last quarter", "note": "vs Q1" }`\n\n### `ring` \u2014 radial % toward a target\nA compact donut-arc badge showing `value / target` as a percent in the center.\n| field | type | does |\n|---|---|---|\n| `value` | number | current value |\n| `target` | number | the goal (default 100) |\n| `label` | string | optional caption below |\n\nMinimal: `{ "type": "ring", "value": 70, "target": 100, "label": "Goal" }`\n\n### `versus` \u2014 two options compared\nTwo mirrored columns with a VS badge between. Uses **`sides`** (exactly two): each `{ title, items[] }`,\neach item `{ label, value? }`.\n\nMinimal: `{ "type": "versus", "sides": [ { "title": "Plan A", "items": [ { "label": "Price", "value": 9 } ] }, { "title": "Plan B", "items": [ { "label": "Price", "value": 29 } ] } ] }`\n\n### `gantt` \u2014 tasks across a time row\nSchedule bars on a **self-contained** time layout (NOT the numeric axis). Uses **`tasks`**:\neach `{ label, start, end }` (positions on the timeline).\n\nMinimal: `{ "type": "gantt", "tasks": [ { "label": "Design", "start": 0, "end": 3 }, { "label": "Build", "start": 2, "end": 7 } ] }`\n\n### `waterfall` \u2014 running total, step by step\nFloating bars showing how a `start` becomes an end through signed deltas, with a final total bar.\nUses **`steps`** `{ label, value }` (value = the signed delta); up green, down red, total dark.\n**Each bar is labeled with its magnitude** (the signed delta above increases, below decreases;\nthe total above its bar) \u2014 on by default; set `showValues:false` to hide them (the bars +\nconnectors remain). Dashed connectors carry the running total bar-to-bar. **`label` is the step\nNAME only** \u2014 the engine renders the number for you, so don\'t embed the value in the label text.\n\nMinimal: `{ "type": "waterfall", "start": 0, "steps": [ { "label": "Q1", "value": 50 }, { "label": "Q2", "value": 30 }, { "label": "Q3", "value": -20 } ] }`\n\n### `swimlane` \u2014 lanes \xD7 phases roadmap\nA bounded grid: **`phases`** (column headers) \xD7 **`lanes`** (rows), each lane\'s `items` placed in a\n`phase` column. Distinct from a routed flowchart.\n\nMinimal: `{ "type": "swimlane", "phases": ["Q1","Q2","Q3"], "lanes": [ { "label": "Eng", "items": [ { "phase": 0, "label": "API" }, { "phase": 2, "label": "v2" } ] } ] }`\n\n### `tierlist` \u2014 ranked buckets of chips\nS/A/B-style rows: each tier a colored label box + its `items` as chips. Uses **`tiers`** `{ label, color?, items[] }`.\n\nMinimal: `{ "type": "tierlist", "tiers": [ { "label": "S", "items": ["Bar","Line"] }, { "label": "A", "items": ["Pie"] } ] }`\n\n### `swot` \u2014 four labeled 2\xD72 cells\nFour tinted cells, each a heading + a short bullet list (single-line items). Uses **`cells`** (up to 4)\n`{ title, items[] }`.\n\nMinimal: `{ "type": "swot", "cells": [ { "title": "Strengths", "items": ["Fast","Cheap"] }, { "title": "Weaknesses", "items": ["New brand"] }, { "title": "Opportunities", "items": ["AI demand"] }, { "title": "Threats", "items": ["Incumbents"] } ] }`\n\n### `dashboard` \u2014 tile many charts into one image\nThe composition layer: lays out **other charts** on a grid and rasterizes them in a single pass\n(one nested SVG \u2192 one PNG). Uses **`tiles`** `{ chart, span? }` \u2014 each `chart` is a **complete spec\nof any other type** (the same object you\'d render standalone), and `span` is `[colspan, rowspan]`\n(default `[1,1]`). Optional **`layout`** `{ cols?, gap?, pad?, tileWidth?, tileHeight? }` (cols\ndefault 3). Board-level `title`, `palette`, `font`, and `background` cascade to any tile that\ndoesn\'t set its own; per-tile watermarks are suppressed in favor of one board watermark. A wide\nchart can span two columns, a tall one two rows. Each tile keeps its OWN scale \u2014 a mixed dashboard\nneeds that; to compare the SAME metric on one shared scale, use a single grouped/multi-series chart\n(or separate boards), not side-by-side tiles.\n\nMinimal: `{ "type": "dashboard", "layout": { "cols": 2 }, "tiles": [ { "chart": { "type": "kpi", "label": "Revenue", "value": 128400, "valuePrefix": "$", "delta": 12.4 } }, { "chart": { "type": "bar", "data": { "labels": ["A","B","C"], "series": [ { "name": "Sales", "values": [8,5,3] } ] } } } ] }`\n\n## What an agent can change (the levers)\n\n- **Fonts** \u2192 `fontFamily`, `fontSize`\n- **Colors** \u2192 `palette` (a named set) for the whole chart, or `data.series[].colors` for exact\n per-item control. The info-design types take **explicit per-element color** the same way: add a\n `color` to any element object \u2014 `cards[].color`, `layers[].color`, `bars[].color` (progress/\n bullet), `stages[].color`, `levels[].color`, `items[].color` (quadrant/leaderboard),\n `events[].color`, `sets[].color`, `steps[].color`, `parts[].color` (waffle). The single-accent /\n gradient types take a **top-level `color`** \u2014 the `gauge` arc, `iconarray` filled icons, and the\n `heatmap`/`calendar` ramp hue. Omit it and color comes from the palette (contrast-aware text\n adapts to whatever fill you choose). (`matrix`/`checklist` glyphs stay semantic \u2014 no override.)\n- **Background** \u2192 `background` (any hex, or `"transparent"`)\n- **Size / aspect** \u2192 `width`, `height`\n- **Branding** \u2192 `watermark` (off for the sites, on for free-tier API output)\n\n## Rules for the engine (and any agent generating specs)\n\n- **Deterministic:** the same spec always produces the exact same SVG bytes. No\n randomness, no time. Safe to cache and snapshot-test.\n- **Defaults are good:** never require a field that has a sensible default. Emit the\n smallest spec that expresses the intent; the engine makes it beautiful.\n- **Unknown `type` is an error** \u2014 only documented types render.\n\n## Roadmap\nMore chart types (line, area, stacked, pie, \u2026) register in `renderSpec`; each inherits\nevery universal field above and gets its own row here. This document becomes a formal\nJSON Schema when the MCP server is built, so the `render_chart` tool can validate specs\nand an LLM can read the field contract directly.\n' : readFileSync(join(here, "../../packages/render-core/SPEC.md"), "utf8");
2864
2868
  var seriesShape = z.object({
2865
2869
  name: z.string().optional().describe("series label (legend)"),
2866
2870
  values: z.array(z.number()).describe("the numbers, aligned to data.labels"),
@@ -2872,7 +2876,7 @@ var pieShape = z.object({
2872
2876
  labels: z.array(z.string()).describe("slice labels"),
2873
2877
  values: z.array(z.number()).describe("slice values, aligned to labels"),
2874
2878
  colors: z.array(z.string()).optional().describe("per-slice color overrides"),
2875
- palette: z.string().optional().describe("per-pie palette override (rare)")
2879
+ palette: z.enum(PALETTE_NAMES).optional().describe("per-pie palette override (rare)")
2876
2880
  });
2877
2881
  var cardShape = z.object({
2878
2882
  label: z.string().optional().describe("the metric name"),
@@ -3021,7 +3025,7 @@ var inputSchema = {
3021
3025
  tileWidth: z.number().optional().describe("px width of a 1-col tile when width is not given (default 440)"),
3022
3026
  tileHeight: z.number().optional().describe("px height of a 1-row tile when height is not given (default 300)")
3023
3027
  }).optional().describe("dashboard ONLY: grid layout controls."),
3024
- palette: z.string().optional().describe("Clean Corporate | Pastel | Vibrant | Monochrome | Cyberpunk | Analogous Shift. Use Monochrome for black & white / laser-printer output \u2014 a grey ramp that stays legible without color."),
3028
+ palette: z.enum(PALETTE_NAMES).optional().describe("Chart palette \u2014 UNKNOWN NAMES ARE REJECTED (no silent fallback to default). Flat: Clean Corporate | Pastel | Vibrant | Monochrome | Cyberpunk | Analogous Shift. Use Monochrome for black & white / laser-printer output. Nested themes (pieofpie) also accepted, e.g. Modern Corporate, Nordic Earth, Cyberpunk Glow."),
3025
3029
  background: z.string().optional().describe('any hex color, or "transparent"'),
3026
3030
  font: z.string().optional().describe("Inter | System | Serif | Mono | Rounded | Condensed"),
3027
3031
  fontFamily: z.string().optional().describe("raw CSS font stack (overrides font)"),
@@ -3053,7 +3057,23 @@ var inputSchema = {
3053
3057
  scale: z.number().optional().describe("png pixel-density multiplier (default 2 = retina)"),
3054
3058
  outputPath: z.string().optional().describe('LOCAL save: write the rendered chart to this file on disk AND still return the inline image. PNG by default, or SVG if the path ends in ".svg". Accepts absolute, ~, or relative paths (parent dirs auto-created). Works on a local stdio install only \u2014 a hosted/remote server has no access to your disk.')
3055
3059
  };
3056
- var server = new McpServer({ name: "slickfast", version: "0.7.1" });
3060
+ var server = new McpServer({ name: "slickfast", version: "0.7.3" });
3061
+ var GOTCHAS = {
3062
+ gantt: ["start/end are timeline positions; the axis spans min(start)\u2026max(end), so a non-zero-based timeline (years, offset quarters) fills the plot correctly."],
3063
+ diverging: ["needs 2 series \u2014 series A is forced positive (up), series B negative (down), regardless of stored sign."],
3064
+ difference: ["needs 2 series; the gap between them is shaded."],
3065
+ kpi: ["sparkline is landscape-only (omit it in portrait).", "the accent bar follows the palette \u2014 pass `color` to fix it."],
3066
+ waffle: ["values are cell counts out of a 10\xD710 grid (100 total); a single part renders as one % gauge."],
3067
+ heatmap: ["`values` is a 2D matrix values[row][col]; `rows` and `columns` are the axis labels."],
3068
+ stacked: ["each ROW is a stack segment shared across every bar; segment color comes from the row, not the series."],
3069
+ stacked100: ["each bar sums to 100%; each row is a shared segment."],
3070
+ pieofpie: ['uses `pies:[\u2026]`, not `data`; each pie\u2019s first slice ("bridge") drills into the next.'],
3071
+ gauge: ["`value` is clamped to [min,max]; set min/max to your scale (default 0\u2013100)."],
3072
+ bullet: ["`bands` are qualitative thresholds on the measure scale; `target` draws the comparison marker."],
3073
+ calendar: ['`days` maps "YYYY-MM-DD" \u2192 value; weekday is computed deterministically (no clock/Date).'],
3074
+ dashboard: ["each tile.chart is a full spec of any other type; tiles keep their OWN scale \u2014 for a shared-scale comparison use one grouped/multi-series chart, not side-by-side tiles."],
3075
+ table: ["year-like integers (1900\u20132100) are shown without thousands separators; other numbers get them."]
3076
+ };
3057
3077
  function typeContract(t) {
3058
3078
  const meta = TYPES.find((x) => x.type === t);
3059
3079
  if (!meta) return null;
@@ -3065,6 +3085,8 @@ function typeContract(t) {
3065
3085
  needsData: meta.needsData,
3066
3086
  honorsToggles: TYPE_TOGGLES[t] || [],
3067
3087
  // which of showValues/showTotal/showPoints this type uses (others are ignored)
3088
+ gotchas: GOTCHAS[t] || [],
3089
+ // non-obvious per-type rules (else discovered by rendering twice)
3068
3090
  example: EXAMPLES[t] || null
3069
3091
  };
3070
3092
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@slickfast/mcp",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "SlickFast — render 47 chart & info-design types (bar, line, pie, KPI, cards, funnel, matrix, gauge, calendar…) plus multi-chart dashboards tiled into one image, as SVG/PNG via MCP. Local and deterministic; nothing leaves your machine.",
5
+ "keywords": ["mcp", "model-context-protocol", "chart", "charts", "dashboard", "data-visualization", "svg", "png", "ai", "agent", "claude", "llm", "deterministic"],
5
6
  "license": "AGPL-3.0-only",
6
7
  "type": "module",
7
8
  "bin": { "slickfast-mcp": "dist/index.js" },