diff --git a/.claude/skills/create-dashboard/SKILL.md b/.claude/skills/create-dashboard/SKILL.md
index 87cc321..1c95bd4 100644
--- a/.claude/skills/create-dashboard/SKILL.md
+++ b/.claude/skills/create-dashboard/SKILL.md
@@ -456,7 +456,7 @@ Line/area (and combo) charts also accept these on `y`:
- `curve` — chart-wide line interpolation: `smooth` | `straight` | `stepline`. Use `straight` for period totals so the line doesn't imply movement between points.
- `dash` — chart-wide dash pattern every series inherits: `dotted`, `dashed`, or `long-dash` (omitted = solid).
-Per-series style overrides live in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: { target: { color: "#EC4899", curve: straight, dash: dashed } }`. Each of `color`/`curve`/`dash` is optional and falls back to the chart-wide default (or the theme palette for colour). Store only genuine differences — a column with no entry inherits everything.
+Per-series style overrides live in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: { target: { color: "#EC4899", curve: straight, dash: dashed } }`. Each of `color`/`curve`/`dash` is optional and falls back to the chart-wide default (or the theme palette for colour). Store only genuine differences — a column with no entry inherits everything. Label/value charts (`pie`/`treemap`/`funnel`) have no y-column series; style their **slices** with a sibling **`slices`** map keyed by the slice's data label: `slices: { Enterprise: { color: "#8B5CF6", label: "Enterprise (2026)" } }`. `color` overrides the palette; `label` renames the displayed slice. Both optional.
Bare column names (`x: month`, `y: [revenue]`) are invalid — always wrap in `{ field: ... }`.
diff --git a/cmd/skill_templates/create-dashboard/SKILL.md b/cmd/skill_templates/create-dashboard/SKILL.md
index f9a42ae..658fb17 100644
--- a/cmd/skill_templates/create-dashboard/SKILL.md
+++ b/cmd/skill_templates/create-dashboard/SKILL.md
@@ -220,7 +220,7 @@ rows:
col: 6
```
-A chart's `x` and `y` are axis encoding objects with a required `field` (bare column names like `x: region` are invalid). `field` may be a single column or a list. On line/area (and combo), `y` also takes `beginAtZero: true` (anchor the value axis at 0), `markers: false` (hide point dots), `curve` (`smooth`/`straight`/`stepline` — chart-wide interpolation; use `straight` for period totals), and `dash` (chart-wide dash pattern every series inherits: `dotted`/`dashed`/`long-dash`; omit for solid). Per-series style overrides go in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: {column: {color: "#EC4899", curve: straight, dash: dashed}}` — each key falls back to the chart-wide default / palette; store only genuine differences.
+A chart's `x` and `y` are axis encoding objects with a required `field` (bare column names like `x: region` are invalid). `field` may be a single column or a list. On line/area (and combo), `y` also takes `beginAtZero: true` (anchor the value axis at 0), `markers: false` (hide point dots), `curve` (`smooth`/`straight`/`stepline` — chart-wide interpolation; use `straight` for period totals), and `dash` (chart-wide dash pattern every series inherits: `dotted`/`dashed`/`long-dash`; omit for solid). Per-series style overrides go in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: {column: {color: "#EC4899", curve: straight, dash: dashed}}` — each key falls back to the chart-wide default / palette; store only genuine differences. Label/value charts (`pie`/`treemap`/`funnel`) style per **slice** instead, via a sibling **`slices`** map keyed by the slice's data label: `slices: {Enterprise: {color: "#8B5CF6", label: "Enterprise (2026)"}}` — `color` overrides the palette, `label` renames the displayed slice; both optional.
Add a second (right-hand) value axis with `y2` when two series live on different scales (e.g. revenue `$` and conversion `%`) and one would otherwise be squashed flat. A y-column plots against the right axis when it is listed in `y2.field`; all other series stay on the left `y` axis. `y2` is a full axis encoding (same `title`/`format`/`beginAtZero`/`curve`/`dash` keys as `y`) and each axis formats its ticks and tooltip values independently. Supported on `line`/`area`/`bar`/`combo`; a column belongs to exactly one axis, `y2.type` must be `number`, and `y2` cannot combine with `stacked`, `horizontal` bars, or `color`. Axis (`y` vs `y2`) and shape (bar vs line via `lines`) are independent — the classic combo is revenue bars on the left with a rate line on the right: `chart: combo`, `lines: [conversion_rate]`, `y: {field: [revenue], format: "$,.0f"}`, `y2: {field: [conversion_rate], format: ".1%"}`.
diff --git a/docs/dashboards/widgets.md b/docs/dashboards/widgets.md
index 40a3a97..53e18ce 100644
--- a/docs/dashboards/widgets.md
+++ b/docs/dashboards/widgets.md
@@ -133,6 +133,8 @@ SQL-backed example:
Per-series style overrides live in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: { revenue: { color: "#EC4899", curve: straight, dash: dashed } }`. Each of `color`/`curve`/`dash` falls back to the chart-wide default (or palette for colour). Store only genuine differences.
+Label/value charts (`pie`, `treemap`, `funnel`) have no y-column series — style their **slices** with a widget-level **`slices`** map keyed by the slice's data label: `slices: { Enterprise: { color: "#8B5CF6", label: "Enterprise (2026)" } }`. `color` overrides the palette; `label` renames the displayed slice. Both optional.
+
Without `format`, ticks fall back to automatic compact formatting. `beginAtZero`, `markers`, `curve`, and `dash` (on `y`) plus the widget-level `series` apply to line/area (and combo) charts. Bare column names (`x: month`, `y: [revenue]`) are not valid — always wrap the column in `{ field: ... }`.
### Second value axis (`y2`)
@@ -211,6 +213,7 @@ Common chart fields:
| `y` | object | Y-axis encoding (`field` may list several series columns; supports `beginAtZero`, `markers`, `curve`, `dash`, and per-series `series` overrides for line/area — see [Axis encoding](#axis-encoding)) |
| `label` | string | Label column for pie, funnel, and treemap charts |
| `value` | object | Value encoding (`{ field: ... }`) for pie, funnel, sankey, heatmap, calendar, treemap, and gauge charts |
+| `slices` | object | Per-slice style overrides for pie/treemap/funnel, keyed by slice label: `{ Enterprise: { color: "#8B5CF6", label: "Enterprise (2026)" } }` |
| `source` | string | Source node column for sankey charts |
| `target` | string | Target/max column for gauge charts (also sankey target node) |
| `open` | string | Open column for candlestick charts |
diff --git a/frontend/src/components/widgets/ChartWidget.tsx b/frontend/src/components/widgets/ChartWidget.tsx
index 236bf16..e807bfc 100644
--- a/frontend/src/components/widgets/ChartWidget.tsx
+++ b/frontend/src/components/widgets/ChartWidget.tsx
@@ -10,7 +10,7 @@ import {
ReferenceLine, ReferenceArea, ErrorBar,
} from "recharts";
import type { TreemapNode } from "recharts";
-import type { ColorScale, Widget, WidgetData } from "../../types/dashboard";
+import type { ColorScale, SliceStyle, Widget, WidgetData } from "../../types/dashboard";
import { axisField, axisFields, buildAxisFormatter, valueField } from "../../lib/format";
import { useTokens } from "../../themes/TemplateProvider";
import { cellColor, resolveScale } from "./conditionalFormat";
@@ -143,7 +143,7 @@ interface TooltipPayloadEntry {
value?: unknown;
}
-function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick, valueFormatter = formatTooltipValue, valueFormatterFor }: {
+function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick, valueFormatter = formatTooltipValue, valueFormatterFor, nameFormatter }: {
active?: boolean;
payload?: TooltipPayloadEntry[];
label?: unknown;
@@ -152,6 +152,8 @@ function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick
// Per-series formatter keyed by dataKey; used for dual-axis charts so each
// row formats against its own axis. Falls back to valueFormatter.
valueFormatterFor?: (dataKey: unknown) => (val: unknown) => string;
+ // Maps a payload entry's name for display (e.g. pie slice renames).
+ nameFormatter?: (name: unknown) => string;
}) {
if (!active || !payload?.length) return null;
return (
@@ -160,7 +162,7 @@ function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick
{payload.map((p, i) => (
- {p.name ?? p.dataKey}
+ {nameFormatter ? nameFormatter(p.name) : (p.name ?? p.dataKey)}
{(valueFormatterFor?.(p.dataKey) ?? valueFormatter)(p.value)}
))}
@@ -1031,6 +1033,7 @@ function FunnelChart({
labelKey,
valueKey,
colors,
+ slices,
tokens,
height,
horizontal = false,
@@ -1040,6 +1043,7 @@ function FunnelChart({
labelKey: string;
valueKey: string;
colors: string[];
+ slices?: Record;
tokens: Record;
height: number;
horizontal?: boolean;
@@ -1069,6 +1073,10 @@ function FunnelChart({
stepConv: i === 0 ? null : raw[i - 1].value > 0 ? (s.value / raw[i - 1].value) * 100 : 0,
// Fade successive stages so depth reads without extra chrome.
opacity: 1 - (i / Math.max(raw.length, 1)) * 0.55,
+ // Per-stage slices[label] override, else the single funnel colour.
+ color: slices?.[s.label]?.color || barColor,
+ // Renamed display name, else the raw label.
+ name: slices?.[s.label]?.label?.trim() || s.label,
}));
if (horizontal) {
@@ -1090,14 +1098,14 @@ function FunnelChart({
- {s.label}
+ {s.name}
- {s.label}
+ {s.name}
{Math.round(s.pctOfTop)}%
@@ -1149,7 +1157,7 @@ function FunnelChart({
curveType(own ? (styleOf(field).curve ?? axisOf(field)?.curve) : widget.y?.curve);
const seriesDash = (field: string, own: boolean) => dashArrayFor(own ? (styleOf(field).dash ?? axisOf(field)?.dash) : widget.y?.dash);
const seriesColor = (field: string, i: number, own: boolean) => (own && styleOf(field).color) || colors[i % colors.length];
+ // Per-slice colour for label/value charts (pie/treemap/funnel), keyed by the
+ // slice's data label; falls back to the palette by position.
+ const sliceColor = (label: unknown, i: number) => widget.slices?.[String(label ?? "")]?.color || colors[i % colors.length];
+ // Display name for a slice: the slices[label].label rename, else the raw label.
+ const sliceLabel = (label: unknown) => widget.slices?.[String(label ?? "")]?.label?.trim() || String(label ?? "");
const yDomain: [number, string] | undefined = widget.y?.beginAtZero ? [0, "auto"] : undefined;
// Point markers: shown on sparse line/area series by default, hidden when
// y.markers is false (dense series stay dotless to avoid clutter).
@@ -1466,13 +1479,16 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu
}
case "pie": {
+ const nameKey = widget.label || "label";
+ // Rename only at the display layer (label/legend/tooltip) so the underlying
+ // data is never mutated; Cells key colours off the raw label.
return (
- `${name ?? ""} ${((percent ?? 0) * 100).toFixed(0)}%`
+ `${sliceLabel(name)} ${((percent ?? 0) * 100).toFixed(0)}%`
}
labelLine={false}
style={AXIS_STYLE}
>
- {chartData.map((_, i) => (
- |
+ {chartData.map((row, i) => (
+ |
))}
- } />
-
+ } />
+
);
@@ -1629,6 +1645,7 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu
labelKey={widget.label || "label"}
valueKey={valueField(widget.value) || "value"}
colors={colors}
+ slices={widget.slices}
tokens={tokens}
height={chartHeight}
horizontal={widget.horizontal}
@@ -1784,9 +1801,9 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu
const labelKey = widget.label || "label";
const valueKey = valueField(widget.value) || "value";
const tmData = chartData.map((d, i) => ({
- name: String(d[labelKey]),
+ name: sliceLabel(d[labelKey]),
size: Number(d[valueKey]) || 0,
- fill: colors[i % colors.length],
+ fill: sliceColor(d[labelKey], i),
}));
return (
diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts
index 2ad1732..5c650be 100644
--- a/frontend/src/types/dashboard.ts
+++ b/frontend/src/types/dashboard.ts
@@ -97,6 +97,7 @@ export interface Widget {
colorScale?: ColorScale; // heatmap: color ramp, same keys as a table gradient
lines?: string[]; // combo: which y series are lines
series?: Record; // per-series line style overrides, keyed by y-column
+ slices?: Record; // per-slice style overrides (pie/treemap/funnel), keyed by slice label
// xmr control limit column; line/bar/forest CI bound — a column name, or a
// per-series map { series column: bound column } for multi-line CI bands.
yMin?: string | Record;
@@ -231,6 +232,12 @@ export interface SeriesStyle {
dash?: "solid" | "dotted" | "dashed" | "long-dash"; // falls back to y.dash
}
+/** Per-slice style override, grouped under widget-level `slices` (keyed by slice label). */
+export interface SliceStyle {
+ color?: string; // #hex; unset uses the palette
+ label?: string; // display name; unset uses the raw data label
+}
+
/** Encoding for the color channel: the category column that splits y into series. */
export interface ColorEncoding {
field: string;
diff --git a/pkg/dashboard/jsloader.go b/pkg/dashboard/jsloader.go
index 0d053e4..1492909 100644
--- a/pkg/dashboard/jsloader.go
+++ b/pkg/dashboard/jsloader.go
@@ -542,6 +542,7 @@ func vnodeToWidget(n *vnode) Widget {
ColorScale: asColorScale(n.Props["colorScale"]),
Lines: asStringSlice(n.Props["lines"]),
Series: asSeriesStyles(n.Props["series"]),
+ Slices: asSliceStyles(n.Props["slices"]),
YMin: asBoundEncoding(n.Props["yMin"]),
YMax: asBoundEncoding(n.Props["yMax"]),
RefLines: asRefLines(n.Props["refLines"]),
@@ -814,6 +815,25 @@ func asSeriesStyles(v interface{}) map[string]SeriesStyle {
return out
}
+func asSliceStyles(v interface{}) map[string]SliceStyle {
+ m, ok := v.(map[string]interface{})
+ if !ok {
+ return nil
+ }
+ out := make(map[string]SliceStyle, len(m))
+ for k, item := range m {
+ sm, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ out[k] = SliceStyle{Color: asString(sm["color"]), Label: asString(sm["label"])}
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
func asColorScale(v interface{}) *ColorScale {
m, ok := v.(map[string]interface{})
if !ok {
diff --git a/pkg/dashboard/model.go b/pkg/dashboard/model.go
index 05310e9..8c893c0 100644
--- a/pkg/dashboard/model.go
+++ b/pkg/dashboard/model.go
@@ -135,15 +135,17 @@ type Widget struct {
ColorScale *ColorScale `yaml:"colorScale,omitempty" json:"colorScale,omitempty"` // heatmap: custom color ramp, same keys as a table gradient
Lines []string `yaml:"lines,omitempty" json:"lines,omitempty"` // combo: which y series render as lines
// Series holds per-series line style overrides keyed by y-column: {column: {color, curve, dash}}.
- Series map[string]SeriesStyle `yaml:"series,omitempty" json:"series,omitempty"`
- YMin *BoundEncoding `yaml:"yMin,omitempty" json:"yMin,omitempty"` // xmr: min control limit column; line/bar/forest: CI lower bound (column or per-series map)
- YMax *BoundEncoding `yaml:"yMax,omitempty" json:"yMax,omitempty"` // xmr: max control limit column; line/bar/forest: CI upper bound (column or per-series map)
- Open string `yaml:"open,omitempty" json:"open,omitempty"` // candlestick: open price column
- High string `yaml:"high,omitempty" json:"high,omitempty"` // candlestick: high price column
- Low string `yaml:"low,omitempty" json:"low,omitempty"` // candlestick: low price column
- Close string `yaml:"close,omitempty" json:"close,omitempty"` // candlestick: close price column
- RefLines []RefLine `yaml:"refLines,omitempty" json:"refLines,omitempty"` // reference guide lines (axis + value + optional label)
- RefBands []RefBand `yaml:"refBands,omitempty" json:"refBands,omitempty"` // shaded reference bands (axis + from/to + optional label)
+ Series map[string]SeriesStyle `yaml:"series,omitempty" json:"series,omitempty"`
+ // Slices holds per-slice style overrides for label/value charts (pie/treemap/funnel), keyed by slice label: {label: {color}}.
+ Slices map[string]SliceStyle `yaml:"slices,omitempty" json:"slices,omitempty"`
+ YMin *BoundEncoding `yaml:"yMin,omitempty" json:"yMin,omitempty"` // xmr: min control limit column; line/bar/forest: CI lower bound (column or per-series map)
+ YMax *BoundEncoding `yaml:"yMax,omitempty" json:"yMax,omitempty"` // xmr: max control limit column; line/bar/forest: CI upper bound (column or per-series map)
+ Open string `yaml:"open,omitempty" json:"open,omitempty"` // candlestick: open price column
+ High string `yaml:"high,omitempty" json:"high,omitempty"` // candlestick: high price column
+ Low string `yaml:"low,omitempty" json:"low,omitempty"` // candlestick: low price column
+ Close string `yaml:"close,omitempty" json:"close,omitempty"` // candlestick: close price column
+ RefLines []RefLine `yaml:"refLines,omitempty" json:"refLines,omitempty"` // reference guide lines (axis + value + optional label)
+ RefBands []RefBand `yaml:"refBands,omitempty" json:"refBands,omitempty"` // shaded reference bands (axis + from/to + optional label)
// Table fields
Columns []TableColumn `yaml:"columns,omitempty" json:"columns,omitempty"`
@@ -554,6 +556,13 @@ type SeriesStyle struct {
Dash string `yaml:"dash,omitempty" json:"dash,omitempty"` // solid | dotted | dashed | long-dash (falls back to y.dash)
}
+// SliceStyle is a per-slice style override for label/value charts, grouped under
+// Widget.Slices and keyed by the slice's displayed label.
+type SliceStyle struct {
+ Color string `yaml:"color,omitempty" json:"color,omitempty"` // #hex; unset uses the palette
+ Label string `yaml:"label,omitempty" json:"label,omitempty"` // display name; unset uses the raw data label
+}
+
type AxisEncoding struct {
Field any `yaml:"field" json:"field"`
Type string `yaml:"type,omitempty" json:"type,omitempty"`
diff --git a/pkg/dashboard/validator.go b/pkg/dashboard/validator.go
index b5b56d5..15955fc 100644
--- a/pkg/dashboard/validator.go
+++ b/pkg/dashboard/validator.go
@@ -438,6 +438,13 @@ func validateChartWidget(prefix string, w *Widget, d *Dashboard) []string {
}
}
+ // Per-slice overrides (widget.slices) for label/value charts, keyed by label.
+ for label, st := range w.Slices {
+ if st.Color != "" && !isHexColor(st.Color) {
+ errs = append(errs, fmt.Sprintf("%s: slices[%q].color must be a hex colour like #EC4899", prefix, label))
+ }
+ }
+
// Second value axis (right side). Only cartesian composed charts render two
// y axes; a right-axis column must be numeric, non-empty, and disjoint from
// the left axis. Stacking across two axes is undefined, so reject the combo.
diff --git a/schemas/dac/dashboard/v1/schema.json b/schemas/dac/dashboard/v1/schema.json
index 98fa6f8..78bf31c 100644
--- a/schemas/dac/dashboard/v1/schema.json
+++ b/schemas/dac/dashboard/v1/schema.json
@@ -355,6 +355,9 @@
"series": {
"$ref": "#/$defs/seriesStyles"
},
+ "slices": {
+ "$ref": "#/$defs/sliceStyles"
+ },
"yMin": {
"$ref": "#/$defs/boundEncoding"
},
@@ -661,6 +664,18 @@
"additionalProperties": false
}
},
+ "sliceStyles": {
+ "description": "Per-slice style overrides for label/value charts (pie/treemap/funnel), keyed by the slice's data label: {label: {color, label}}. The nested `label` renames the slice's displayed name.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "color": { "type": "string", "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" },
+ "label": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
+ },
"valueEncoding": {
"type": "object",
"required": ["field"],