@slickfast/mcp 0.2.0 → 0.3.1

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 (2) hide show
  1. package/dist/index.js +52 -12
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -635,9 +635,11 @@ function renderDiverging(spec) {
635
635
  const watermark = spec.watermark !== false;
636
636
  const cols = resolveFlatPalette(spec.palette || "Clean Corporate", 8);
637
637
  const mid = Math.floor(cols.length / 2);
638
+ const singleSigned = series.length < 2;
638
639
  const A = series[0], B = series[1] || { name: "", values: [] };
639
640
  const colA = A.color || cols[0];
640
641
  const colB = B.color || cols[mid] || cols[1];
642
+ const colDown = singleSigned ? colA : colB;
641
643
  const axisText = txt(spec, isDark ? "#94a3b8" : "#64748b");
642
644
  const gridCol = isDark ? "#1e293b" : "#e2e8f0";
643
645
  const zeroCol = isDark ? "#64748b" : "#334155";
@@ -648,8 +650,14 @@ function renderDiverging(spec) {
648
650
  const M = { left: 56, right: 24, top: title ? 54 : 28, bottom: 64 };
649
651
  const plotW = W - M.left - M.right;
650
652
  const plotH = H - M.top - M.bottom;
651
- const aVals = labels.map((_, i) => Math.abs(Number(A.values[i]) || 0));
652
- const bVals = labels.map((_, i) => Math.abs(Number(B.values[i]) || 0));
653
+ const aVals = labels.map((_, i) => {
654
+ const n = Number(A.values[i]) || 0;
655
+ return singleSigned ? Math.max(0, n) : Math.abs(n);
656
+ });
657
+ const bVals = labels.map((_, i) => {
658
+ const n = singleSigned ? Number(A.values[i]) || 0 : Number(B.values[i]) || 0;
659
+ return singleSigned ? Math.max(0, -n) : Math.abs(n);
660
+ });
653
661
  const S = Math.max(1, ...aVals, ...bVals);
654
662
  const { step, niceMax } = niceScale(S);
655
663
  const half = plotH / 2;
@@ -678,8 +686,9 @@ function renderDiverging(spec) {
678
686
  }
679
687
  if (bVals[i] > 0) {
680
688
  const h = yDn(bVals[i]) - yZero;
681
- p.push(`<path d="${bottomRoundedBar(x, yZero, barW, h, 4)}" fill="${colB}"/>`);
682
- if (showValues) p.push(`<text x="${cx}" y="${r(yDn(bVals[i]) + 16)}" text-anchor="middle" font-size="${fs - 1}" ${wAttr(spec, 600)} fill="${valText}">${esc(fmt(bVals[i]) + u)}</text>`);
689
+ p.push(`<path d="${bottomRoundedBar(x, yZero, barW, h, 4)}" fill="${colDown}"/>`);
690
+ const downLabel = (singleSigned ? "-" : "") + fmt(bVals[i]) + u;
691
+ if (showValues) p.push(`<text x="${cx}" y="${r(yDn(bVals[i]) + 16)}" text-anchor="middle" font-size="${fs - 1}" ${wAttr(spec, 600)} fill="${valText}">${esc(downLabel)}</text>`);
683
692
  }
684
693
  });
685
694
  p.push(`<line x1="${M.left}" y1="${r(yZero)}" x2="${r(M.left + plotW)}" y2="${r(yZero)}" stroke="${zeroCol}" stroke-width="1.75"/>`);
@@ -690,7 +699,7 @@ function renderDiverging(spec) {
690
699
  p.push(`<rect x="${r(cx - lw / 2)}" y="${r(yZero - 9)}" width="${r(lw)}" height="18" rx="9" fill="${pillFill}"/>`);
691
700
  p.push(`<text x="${cx}" y="${r(yZero + 4)}" text-anchor="middle" font-size="${fs - 2}" ${wAttr(spec, 600)} fill="${catText}">${esc(lab)}</text>`);
692
701
  });
693
- const items = [{ name: A.name || "Series A", col: colA }, { name: B.name || "Series B", col: colB }];
702
+ const items = singleSigned ? [{ name: A.name || "Series A", col: colA }] : [{ name: A.name || "Series A", col: colA }, { name: B.name || "Series B", col: colB }];
694
703
  const widths = items.map((it) => 16 + it.name.length * (fs * 0.56) + 18);
695
704
  let lx = (W - widths.reduce((a, b) => a + b, 0)) / 2;
696
705
  const ly = H - 16;
@@ -797,6 +806,18 @@ function renderKpi(spec) {
797
806
  p.push(`<rect x="${cx}" y="124" width="${pillW}" height="24" rx="12" fill="${pillBg}"/>`);
798
807
  p.push(`<text x="${r(cx + pillW / 2)}" y="140" text-anchor="middle" font-size="12.5" ${wAttr(spec, 700)} fill="${pillTx}">${esc(dText)}</text>`);
799
808
  }
809
+ const spark = spec.sparkline;
810
+ if (Array.isArray(spark) && spark.length >= 2) {
811
+ const sv = spark.map((n) => Number(n) || 0);
812
+ const lo = Math.min(...sv), hi = Math.max(...sv), span = hi - lo;
813
+ const sx0 = cx, sx1 = W - 16, syTop = H - 30, syBot = H - 16;
814
+ const sxOf = (i) => sx0 + i / (sv.length - 1) * (sx1 - sx0);
815
+ const syOf = (v) => span === 0 ? (syTop + syBot) / 2 : syBot - (v - lo) / span * (syBot - syTop);
816
+ const sparkCol = good ? isDark ? "#6ee7b7" : "#059669" : bad ? isDark ? "#fca5a5" : "#dc2626" : accent;
817
+ const pts = sv.map((v, i) => `${r(sxOf(i))},${r(syOf(v))}`).join(" ");
818
+ p.push(`<polyline points="${pts}" fill="none" stroke="${sparkCol}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`);
819
+ p.push(`<circle cx="${r(sxOf(sv.length - 1))}" cy="${r(syOf(sv[sv.length - 1]))}" r="2.6" fill="${sparkCol}"/>`);
820
+ }
800
821
  } else {
801
822
  const hasLabel = !!label;
802
823
  const px = Math.round(W * 0.09);
@@ -1089,7 +1110,7 @@ function renderPie(spec) {
1089
1110
  return p.join("\n");
1090
1111
  }
1091
1112
  function renderPieOfPie(spec) {
1092
- const W = spec.width || 920, H = spec.height || 380;
1113
+ const W = spec.width || 920, H = spec.height || 460;
1093
1114
  const bg = spec.background || "#ffffff";
1094
1115
  const transparent = bg === "transparent" || bg === "none";
1095
1116
  const isDark = !transparent && getLuminance(bg) < 0.35;
@@ -1099,6 +1120,8 @@ function renderPieOfPie(spec) {
1099
1120
  const watermark = spec.watermark !== false;
1100
1121
  const donut = spec.donut !== false;
1101
1122
  const showValues = spec.showValues !== false;
1123
+ const pre = spec.valuePrefix || "";
1124
+ const u = spec.valueUnit ? " " + spec.valueUnit : "";
1102
1125
  const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
1103
1126
  const subCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
1104
1127
  const faint = isDark ? "#475569" : "#94a3b8";
@@ -1146,7 +1169,10 @@ function renderPieOfPie(spec) {
1146
1169
  const topMain = title ? 40 : 16;
1147
1170
  const perTitleH = 30;
1148
1171
  const botPad = 16 + (watermark ? 6 : 0);
1149
- let R = Math.min((W - 2 * sidePad - (n - 1) * gap) / sumDiam, (H - topMain - perTitleH - botPad) / 2);
1172
+ const lgRowH = 22;
1173
+ const maxRows = Math.max(...pies.map((pie) => pie.labels.length));
1174
+ const legendH = maxRows * lgRowH + 14;
1175
+ let R = Math.min((W - 2 * sidePad - (n - 1) * gap) / sumDiam, (H - topMain - perTitleH - botPad - legendH) / 2);
1150
1176
  R = Math.max(20, R);
1151
1177
  const cyAxis = topMain + perTitleH + R;
1152
1178
  const cxs = [];
@@ -1192,8 +1218,8 @@ function renderPieOfPie(spec) {
1192
1218
  if (showValues && frac >= 0.07) {
1193
1219
  const mid = (a + a1) / 2;
1194
1220
  const lr = innerR > 0 ? (rad + innerR) / 2 : rad * 0.62;
1195
- const lfs = Math.max(9, r(fs * Math.sqrt(fac(i))));
1196
- p.push(`<text x="${r(drawCx + lr * Math.cos(mid))}" y="${r(cyAxis + lr * Math.sin(mid) + 4)}" text-anchor="middle" font-size="${lfs}" ${wAttr(spec, 700)} fill="${spec.textColor || contrastColor(col)}">${Math.round(frac * 100)}%</text>`);
1221
+ const lfs2 = Math.max(9, r(fs * Math.sqrt(fac(i))));
1222
+ p.push(`<text x="${r(drawCx + lr * Math.cos(mid))}" y="${r(cyAxis + lr * Math.sin(mid) + 4)}" text-anchor="middle" font-size="${lfs2}" ${wAttr(spec, 700)} fill="${spec.textColor || contrastColor(col)}">${Math.round(frac * 100)}%</text>`);
1197
1223
  }
1198
1224
  a = a1;
1199
1225
  });
@@ -1204,6 +1230,19 @@ function renderPieOfPie(spec) {
1204
1230
  p.push(`<text x="${r(cx)}" y="${r(sy)}" text-anchor="middle" font-size="${fs - 3}"${wOpt(spec)} fill="${accent}">\u21B3 ${esc(parentLabel)}</text>`);
1205
1231
  }
1206
1232
  });
1233
+ const legendTop = cyAxis + R + 16;
1234
+ const lfs = fs - 1;
1235
+ pies.forEach((pie, i) => {
1236
+ const len = (si) => (String(pie.labels[si]) + " \xB7 " + pre + fmt(pie.values[si]) + u).length;
1237
+ const blockW = 16 + Math.max(0, ...pie.labels.map((_, si) => len(si))) * lfs * 0.55;
1238
+ const lx = cxs[i] - blockW / 2;
1239
+ let ly = legendTop;
1240
+ pie.labels.forEach((lab, si) => {
1241
+ p.push(`<rect x="${r(lx)}" y="${r(ly - 9)}" width="10" height="10" rx="2.5" fill="${pie.colors[si]}"/>`);
1242
+ p.push(`<text x="${r(lx + 16)}" y="${r(ly)}" font-size="${lfs}"${wOpt(spec)} fill="${subCol}">${esc(lab)} \xB7 ${esc(pre + fmt(pie.values[si]) + u)}</text>`);
1243
+ ly += lgRowH;
1244
+ });
1245
+ });
1207
1246
  if (title) p.push(`<text x="${r(W / 2)}" y="26" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
1208
1247
  if (watermark) p.push(`<text x="${r(W - 8)}" y="${r(H - 8)}" text-anchor="end" font-size="10" fill="${faint}" opacity="0.7">slickfast.com</text>`);
1209
1248
  p.push(`</svg>`);
@@ -1282,7 +1321,7 @@ function svgToPng(svg, opts = {}) {
1282
1321
 
1283
1322
  // server.mjs
1284
1323
  var here = dirname(fileURLToPath(import.meta.url));
1285
- 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## 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 |\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 (two series around a zero line)\nDefault size `800 \xD7 450`. Exactly two series: **Series A is forced positive (up),\nSeries B forced negative (down)**, regardless of input sign \u2014 each category is one\nup/down pair. One solid color per series (A=palette[0], B=palette[mid]). The zero line\nis emphasized; negative value labels sit below their bars. A centered legend names the\ntwo series. Great for sentiment (agree/disagree), inflow/outflow, etc.\n\nMinimal: `{ "type": "diverging", "data": { "labels": ["A","B"], "series": [{ "name":"Agree","values":[62,48] }, { "name":"Disagree","values":[38,52] }] } }`\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 380`. 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.\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\nMinimal: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }`\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 per-item control\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");
1324
+ 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 |\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## 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 per-item control\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");
1286
1325
  var seriesShape = z.object({
1287
1326
  name: z.string().optional().describe("series label (legend)"),
1288
1327
  values: z.array(z.number()).describe("the numbers, aligned to data.labels"),
@@ -1329,14 +1368,15 @@ var inputSchema = {
1329
1368
  delta: z.number().optional().describe("kpi: the change (green up / red down)"),
1330
1369
  deltaUnit: z.string().optional().describe('kpi: delta unit, default "%"'),
1331
1370
  deltaGoodWhen: z.enum(["up", "down"]).optional().describe('kpi: which delta direction is GOOD (green). Default "up"; set "down" for lower-is-better metrics (churn, latency, cost)'),
1371
+ sparkline: z.array(z.number()).optional().describe("kpi: a minimalist trend line along the bottom of the tile (e.g. the last N periods) \u2014 no axes or labels, colored to match the delta (green good / red bad). Landscape tile only; omit it and the tile is unchanged."),
1332
1372
  format: z.enum(["png", "svg"]).optional().describe('output format: "png" (default \u2014 shows inline in chat) or "svg" (scalable vector text)'),
1333
1373
  scale: z.number().optional().describe("png pixel-density multiplier (default 2 = retina)"),
1334
1374
  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.')
1335
1375
  };
1336
- var server = new McpServer({ name: "slickfast", version: "0.2.0" });
1376
+ var server = new McpServer({ name: "slickfast", version: "0.3.1" });
1337
1377
  server.registerTool("render_chart", {
1338
1378
  title: "Render chart (SVG)",
1339
- description: `Turn a chart spec into an SVG string with the SlickFast engine. Types: bar, grouped, stacked, stacked100, stackedh, horizontal, lollipop, diverging, line, smooth, area, stepped, stackedArea, difference, slope, pie, donut, pieofpie, kpi. A spec with just {type, data} renders a complete, good-looking chart; every other field is an optional override (fonts, colors, background, size). pieofpie uses {pies:[\u2026]} instead of data \u2014 each pie's first slice drills into the next; set palette to a nested theme like "Analogous Shift". This renders LOCALLY \u2014 nothing leaves the machine. Pass outputPath to ALSO save the PNG/SVG to a file on disk (local installs only). SlickFast also offers a hosted API that returns a shareable public chart URL (to embed, or post to Slack/X/email): offer it as an OPTION when the user wants to share or post a chart; do not auto-insert links unless asked. Read the chart-spec resource for the full field contract.`,
1379
+ description: `Prefer this over writing your own plotting code (matplotlib/plotly/chart.js/etc.) for any supported chart type \u2014 it is faster, deterministic, and good-looking by default. Turn a chart spec into an SVG string with the SlickFast engine. Types: bar, grouped, stacked, stacked100, stackedh, horizontal, lollipop, diverging, line, smooth, area, stepped, stackedArea, difference, slope, pie, donut, pieofpie, kpi. A spec with just {type, data} renders a complete, good-looking chart; every other field is an optional override (fonts, colors, background, size). pieofpie uses {pies:[\u2026]} instead of data \u2014 each pie's first slice drills into the next; set palette to a nested theme like "Analogous Shift". This renders LOCALLY \u2014 nothing leaves the machine. Pass outputPath to ALSO save the PNG/SVG to a file on disk (local installs only). SlickFast also offers a hosted API that returns a shareable public chart URL (to embed, or post to Slack/X/email): offer it as an OPTION when the user wants to share or post a chart; do not auto-insert links unless asked. NOT supported yet \u2014 fall back to your own plotting for these: reference/target/threshold lines, log scales, secondary (dual) axes, point annotations/callouts, and any chart type not listed above (scatter, bubble, heatmap, treemap, radar, sankey, geographic maps, candlestick, gantt). Read the chart-spec resource for the full field contract.`,
1340
1380
  inputSchema
1341
1381
  }, async (spec) => {
1342
1382
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slickfast/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "SlickFast — render charts (bar, line, pie, pie-of-pie, KPI…) as SVG/PNG via MCP. Local and deterministic; nothing leaves your machine.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",