@iloveagents/foundry-web-ui 0.22.0 → 0.24.0

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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The chart subpath — `@iloveagents/foundry-web-ui/chart`.
3
+ *
4
+ * Charts are an optional tier: recharts is a real dependency, and a host
5
+ * that never draws one should not carry it into its module graph. The
6
+ * root barrel therefore does not re-export this file (same reasoning as
7
+ * `@iloveagents/foundry-agent/msal`); import from the subpath.
8
+ */
9
+ export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, useChart, compactAxisTick, type ChartContainerProps, type ChartTooltipContentProps, type ChartLegendContentProps, } from "./components/chart/chart.js";
10
+ export { FOLD_COLOR, OTHER_LABEL, categoryColorResolver, chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, type ChartConfig, type ChartSeriesConfig, } from "./components/chart/chart-config.js";
11
+ export { StatCard, type StatCardProps } from "./components/chart/stat-card.js";
12
+ export { ChartCard, DashboardGrid, DashboardTile, type ChartCardProps, type DashboardGridProps, type DashboardTileProps, } from "./components/chart/chart-card.js";
13
+ export { TimeSeriesChart, type TimeSeriesChartProps, } from "./components/chart/time-series-chart.js";
14
+ export { CategoryBarChart, type CategoryBarChartProps, } from "./components/chart/category-bar-chart.js";
15
+ export { DonutChart, type DonutChartProps } from "./components/chart/donut-chart.js";
package/dist/chart.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The chart subpath — `@iloveagents/foundry-web-ui/chart`.
3
+ *
4
+ * Charts are an optional tier: recharts is a real dependency, and a host
5
+ * that never draws one should not carry it into its module graph. The
6
+ * root barrel therefore does not re-export this file (same reasoning as
7
+ * `@iloveagents/foundry-agent/msal`); import from the subpath.
8
+ */
9
+ export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, useChart, compactAxisTick, } from "./components/chart/chart.js";
10
+ export { FOLD_COLOR, OTHER_LABEL, categoryColorResolver, chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, } from "./components/chart/chart-config.js";
11
+ export { StatCard } from "./components/chart/stat-card.js";
12
+ export { ChartCard, DashboardGrid, DashboardTile, } from "./components/chart/chart-card.js";
13
+ export { TimeSeriesChart, } from "./components/chart/time-series-chart.js";
14
+ export { CategoryBarChart, } from "./components/chart/category-bar-chart.js";
15
+ export { DonutChart } from "./components/chart/donut-chart.js";
@@ -0,0 +1,31 @@
1
+ import * as React from "react";
2
+ import { type ChartConfig } from "./chart-config.js";
3
+ /**
4
+ * A ranked categorical breakdown ("tokens per department", "runs per
5
+ * workflow"). Horizontal by default — category names read best on the y
6
+ * axis. One measure = ONE hue (this is a magnitude comparison, not an
7
+ * identity chart); `colorByCategory` switches to per-category slot colors
8
+ * for the identity case, and `maxBars` folds the tail into "Other" instead
9
+ * of inventing a sixth color.
10
+ */
11
+ export interface CategoryBarChartProps {
12
+ data: Array<Record<string, unknown>>;
13
+ categoryKey: string;
14
+ valueKey: string;
15
+ /** Labels/colors. The `valueKey` entry names the measure in the tooltip. */
16
+ config?: ChartConfig;
17
+ /** Horizontal bars (default). Set false for vertical columns. */
18
+ horizontal?: boolean;
19
+ /** Fold rows beyond this count into an "Other" bar. */
20
+ maxBars?: number;
21
+ /** Give each category its own theme slot color (identity, not magnitude). */
22
+ colorByCategory?: boolean;
23
+ height?: number;
24
+ /** Fixed pixel width opts out of responsive measurement (tests, embeds). */
25
+ width?: number;
26
+ valueFormatter?: (value: number) => React.ReactNode;
27
+ /** Renders a category's axis/tooltip label. Defaults to the raw value. */
28
+ categoryFormatter?: (value: unknown) => string;
29
+ className?: string;
30
+ }
31
+ export declare function CategoryBarChart({ data, categoryKey, valueKey, config, horizontal, maxBars, colorByCategory, height, width, valueFormatter, categoryFormatter, className, }: CategoryBarChartProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import * as RechartsPrimitive from "recharts";
4
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, compactAxisTick } from "./chart.js";
5
+ import { CHART_SLOT_COUNT, categoryColorResolver, foldCategoryTail, seriesColor, } from "./chart-config.js";
6
+ const EMPTY_CONFIG = {};
7
+ export function CategoryBarChart({ data, categoryKey, valueKey, config = EMPTY_CONFIG, horizontal = true, maxBars, colorByCategory = false, height, width, valueFormatter, categoryFormatter, className, }) {
8
+ // Identity coloring caps at the palette (5 + Other); a single-hue ranked
9
+ // list caps at what stays readable. Both fold rather than overflow.
10
+ const barDefault = colorByCategory ? CHART_SLOT_COUNT : 12;
11
+ // A non-finite cap keeps THIS chart's default, not the palette-wide one.
12
+ const effectiveMax = Number.isFinite(maxBars) ? maxBars : barDefault;
13
+ const rows = React.useMemo(() => {
14
+ // This is a RANKED breakdown by contract, and the fold keeps the head —
15
+ // rank first so an unsorted input never folds its biggest categories.
16
+ const ranked = [...data].sort((a, b) => (Number(b[valueKey]) || 0) - (Number(a[valueKey]) || 0));
17
+ return foldCategoryTail(ranked, { categoryKey, valueKey, max: effectiveMax });
18
+ }, [data, categoryKey, valueKey, effectiveMax]);
19
+ // Identity resolved from the ORIGINAL categories, with the measure's own
20
+ // config entry excluded — it labels the value column, it is not a
21
+ // category and must not shift the order.
22
+ const categoryColor = React.useMemo(() => categoryColorResolver(config, rows.map((row) => String(row[categoryKey] ?? "")), { exclude: valueKey }), [config, rows, categoryKey, valueKey]);
23
+ const fixed = width !== undefined;
24
+ const resolvedHeight = height ?? (horizontal ? Math.max(160, rows.length * 36 + 24) : 240);
25
+ const formatCategory = categoryFormatter ?? ((v) => String(v ?? ""));
26
+ return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height: resolvedHeight, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.BarChart, { data: rows, layout: horizontal ? "vertical" : "horizontal", width: width, height: fixed ? resolvedHeight : undefined, margin: { top: 4, right: 12, bottom: 0, left: 0 }, children: [horizontal ? (_jsxs(_Fragment, { children: [_jsx(RechartsPrimitive.XAxis, { type: "number", tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(RechartsPrimitive.YAxis, { type: "category", dataKey: categoryKey, width: 140, tickLine: false, axisLine: false, tickFormatter: formatCategory })] })) : (_jsxs(_Fragment, { children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: categoryKey, tickLine: false, axisLine: false, tickMargin: 8, tickFormatter: formatCategory }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick })] })), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: (label) => formatCategory(label), valueFormatter: valueFormatter, indicatorColor: colorByCategory ? (_key, label) => categoryColor(String(label)) : undefined }) }), _jsx(RechartsPrimitive.Bar, { dataKey: valueKey, fill: seriesColor(config, valueKey), maxBarSize: 28, radius: horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0], isAnimationActive: false, children: colorByCategory &&
27
+ rows.map((row, i) => {
28
+ const category = String(row[categoryKey] ?? i);
29
+ return (_jsx(RechartsPrimitive.Cell, { fill: categoryColor(category) }, `${i}-${category}`));
30
+ }) })] }) }));
31
+ }
@@ -0,0 +1,27 @@
1
+ import * as React from "react";
2
+ /**
3
+ * The frame every dashboard chart sits in, and the bento grid that lays the
4
+ * frames out. A shared frame is what makes a dashboard read as one surface:
5
+ * the same header anatomy, the same padding, the same border on every tile,
6
+ * so only the DATA differs from card to card.
7
+ */
8
+ export interface ChartCardProps extends Omit<React.ComponentProps<"div">, "title"> {
9
+ title: React.ReactNode;
10
+ description?: React.ReactNode;
11
+ /** Right-aligned header slot (a select, a toggle, a menu). */
12
+ action?: React.ReactNode;
13
+ footer?: React.ReactNode;
14
+ }
15
+ declare const ChartCard: React.ForwardRefExoticComponent<Omit<ChartCardProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
16
+ export interface DashboardGridProps extends React.ComponentProps<"div"> {
17
+ /** Columns at the desktop breakpoint (1–4). Tiles span via `DashboardTile`. */
18
+ columns?: 1 | 2 | 3 | 4;
19
+ }
20
+ /** Mobile-first: one column on small screens, `columns` from `lg:` up. */
21
+ declare const DashboardGrid: React.ForwardRefExoticComponent<Omit<DashboardGridProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
22
+ export interface DashboardTileProps extends React.ComponentProps<"div"> {
23
+ /** How many grid columns this tile spans at the desktop breakpoint. */
24
+ span?: 1 | 2 | 3 | 4;
25
+ }
26
+ declare const DashboardTile: React.ForwardRefExoticComponent<Omit<DashboardTileProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
27
+ export { ChartCard, DashboardGrid, DashboardTile };
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+ const ChartCard = React.forwardRef(({ title, description, action, footer, className, children, ...props }, ref) => (_jsxs("div", { ref: ref, "data-chart-card": "", className: cn("flex flex-col rounded-xl border border-border bg-card p-4 text-card-foreground", className), ...props, children: [_jsxs("div", { className: "mb-3 flex items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h3", { className: "truncate text-sm font-semibold", children: title }), description && _jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: description })] }), action && _jsx("div", { className: "flex shrink-0 items-center gap-1.5", children: action })] }), _jsx("div", { className: "min-h-0 flex-1", children: children }), footer && (_jsx("div", { className: "mt-3 border-t border-border pt-2 text-xs text-muted-foreground", children: footer }))] })));
5
+ ChartCard.displayName = "ChartCard";
6
+ const GRID_COLUMNS = {
7
+ 1: "lg:grid-cols-1",
8
+ 2: "lg:grid-cols-2",
9
+ 3: "lg:grid-cols-3",
10
+ 4: "lg:grid-cols-4",
11
+ };
12
+ /** Mobile-first: one column on small screens, `columns` from `lg:` up. */
13
+ const DashboardGrid = React.forwardRef(({ columns = 3, className, ...props }, ref) => (_jsx("div", { ref: ref, "data-dashboard-grid": "", className: cn("grid grid-cols-1 gap-4", GRID_COLUMNS[columns], className), ...props })));
14
+ DashboardGrid.displayName = "DashboardGrid";
15
+ const TILE_SPANS = {
16
+ 1: "lg:col-span-1",
17
+ 2: "lg:col-span-2",
18
+ 3: "lg:col-span-3",
19
+ 4: "lg:col-span-4",
20
+ };
21
+ const DashboardTile = React.forwardRef(({ span = 1, className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("min-w-0", TILE_SPANS[span], className), ...props })));
22
+ DashboardTile.displayName = "DashboardTile";
23
+ export { ChartCard, DashboardGrid, DashboardTile };
@@ -0,0 +1,119 @@
1
+ import type * as React from "react";
2
+ /**
3
+ * Chart semantics — series → label/color resolution and shared formatting.
4
+ *
5
+ * Mirrors the data-table split (`facets.ts` vs `data-table.tsx`): this file
6
+ * owns MEANING — what a series is called, which color identifies it, how a
7
+ * number reads — while `chart.tsx` owns what is drawn. Keeping semantics out
8
+ * of the render tree lets hosts (and agent-written renderers) reason about a
9
+ * chart's contents without rendering it.
10
+ */
11
+ export interface ChartSeriesConfig {
12
+ /** Human label shown in legends and tooltips. Falls back to the key. */
13
+ label?: React.ReactNode;
14
+ /**
15
+ * Explicit color — any CSS color, including a theme token reference such
16
+ * as `var(--chart-3)`. When omitted, the series takes the theme's
17
+ * categorical slot for its DECLARED position (see {@link chartSlotColor}).
18
+ */
19
+ color?: string;
20
+ }
21
+ /**
22
+ * One entry per series/category key. The declaration ORDER is the identity
23
+ * order: colors are assigned by position in this object, so a series keeps
24
+ * its color when a filter removes its neighbours — color follows the entity,
25
+ * never its rank.
26
+ */
27
+ export type ChartConfig = Record<string, ChartSeriesConfig>;
28
+ /** The theme defines exactly five categorical slots (`--chart-1..5`). */
29
+ export declare const CHART_SLOT_COUNT = 5;
30
+ /**
31
+ * The theme's categorical palette, assigned in fixed order. More than five
32
+ * series is a design smell, not a palette problem: fold the tail into
33
+ * "Other" (DonutChart does this via `maxSlices`) or split into small
34
+ * multiples. The modulo below only keeps an overflowing series defined —
35
+ * it is not an invitation to cycle.
36
+ */
37
+ export declare function chartSlotColor(index: number): string;
38
+ /** Resolved color for a series: explicit config first, theme slot second. */
39
+ export declare function seriesColor(config: ChartConfig, key: string): string;
40
+ /** The label a folded tail wears, and the muted color that marks it. */
41
+ export declare const OTHER_LABEL = "Other";
42
+ export declare const FOLD_COLOR = "var(--muted-foreground)";
43
+ /**
44
+ * The color resolver for a CATEGORICAL chart, where every category is an
45
+ * identity rather than a series.
46
+ *
47
+ * Two requirements pull against each other, and both are real:
48
+ * - a DECLARED category must keep its color when a filter removes its
49
+ * neighbours (identity follows the entity, never its rank), and
50
+ * - a DRAWN category must never take the muted past-the-palette color
51
+ * just because the config also names categories this chart does not
52
+ * draw.
53
+ *
54
+ * Resolved by giving each side its own claim on the palette. A declared
55
+ * key OWNS its declared index — reserved whether or not it is drawn, which
56
+ * is what makes it stable — and undeclared drawn categories fill the slots
57
+ * no declaration claimed, in drawn order. Past the palette, or with every
58
+ * slot spoken for, a category takes the muted fold color rather than
59
+ * wrapping modulo five onto a color another category already owns.
60
+ *
61
+ * So: declare the categories whose colors must not move, and the rest sort
62
+ * themselves out.
63
+ *
64
+ * `exclude` drops the measure's own config entry (a bar chart labels its
65
+ * value column there); it is not a category and must not shift the order.
66
+ */
67
+ export declare function categoryColorResolver(config: ChartConfig, categories: Iterable<string>, options?: {
68
+ exclude?: string;
69
+ otherLabel?: string;
70
+ }): (category: string) => string;
71
+ /** Resolved label for a series: explicit config first, the key itself second. */
72
+ export declare function seriesLabel(config: ChartConfig, key: string): React.ReactNode;
73
+ /**
74
+ * Series keys are data-column names, which allow characters a CSS custom
75
+ * ident does not (`metrics.tokens`, `model name`). Everything that writes
76
+ * or reads a per-series variable goes through this one sanitizer, so the
77
+ * two sides can never disagree.
78
+ */
79
+ export declare function seriesVarName(key: string): string;
80
+ /**
81
+ * The `var()` reference marks use for their series color.
82
+ *
83
+ * A key with no configured series resolves to an UNDEFINED custom property,
84
+ * which makes `fill` fall back to black and `stroke` to none — a silent
85
+ * black band. The fallback keeps an undeclared series visible.
86
+ */
87
+ export declare function seriesVar(key: string, fallback?: string): string;
88
+ /**
89
+ * The per-series CSS custom properties a `ChartContainer` injects, so chart
90
+ * marks can reference {@link seriesVar} and pick up theme changes live.
91
+ */
92
+ export declare function chartStyleVars(config: ChartConfig): Record<string, string>;
93
+ /** 1029562 → "1M", 63093480 → "63.1M". For axes and stat tiles. */
94
+ export declare function formatCompactNumber(value: number): string;
95
+ /**
96
+ * Axis label for a time bucket as the usage/statistics APIs emit them:
97
+ * `"2026-08-26"` → localized "Aug 26", an ISO datetime (hour grain) →
98
+ * localized "14:00". Anything else passes through unchanged, so categorical
99
+ * x-axes can share the same formatter.
100
+ */
101
+ export declare function formatBucketLabel(bucket: unknown): string;
102
+ /**
103
+ * Fold a category list's tail into a single "Other" row once it exceeds
104
+ * `max` rows — the discipline for categorical charts: a 9th slice is never
105
+ * a 9th color.
106
+ *
107
+ * Ranks by value FIRST. The fold keeps the head, so on an unranked list it
108
+ * would swallow whatever happened to be sorted late — a donut of six
109
+ * equal-ish categories plus one dominant one at the end rendered as four
110
+ * slivers and an unlabeled 96% "Other". Ranking here rather than trusting
111
+ * callers makes that unreachable, including for charts a builder agent
112
+ * writes.
113
+ */
114
+ export declare function foldCategoryTail<T extends Record<string, unknown>>(rows: T[], options: {
115
+ categoryKey: keyof T & string;
116
+ valueKey: keyof T & string;
117
+ max?: number;
118
+ otherLabel?: string;
119
+ }): T[];
@@ -0,0 +1,187 @@
1
+ /** The theme defines exactly five categorical slots (`--chart-1..5`). */
2
+ export const CHART_SLOT_COUNT = 5;
3
+ /**
4
+ * The theme's categorical palette, assigned in fixed order. More than five
5
+ * series is a design smell, not a palette problem: fold the tail into
6
+ * "Other" (DonutChart does this via `maxSlices`) or split into small
7
+ * multiples. The modulo below only keeps an overflowing series defined —
8
+ * it is not an invitation to cycle.
9
+ */
10
+ export function chartSlotColor(index) {
11
+ return `var(--chart-${(Math.max(0, index) % CHART_SLOT_COUNT) + 1})`;
12
+ }
13
+ /** Resolved color for a series: explicit config first, theme slot second. */
14
+ export function seriesColor(config, key) {
15
+ const explicit = config[key]?.color;
16
+ if (explicit)
17
+ return explicit;
18
+ const declaredIndex = Object.keys(config).indexOf(key);
19
+ return chartSlotColor(declaredIndex === -1 ? 0 : declaredIndex);
20
+ }
21
+ /** The label a folded tail wears, and the muted color that marks it. */
22
+ export const OTHER_LABEL = "Other";
23
+ export const FOLD_COLOR = "var(--muted-foreground)";
24
+ /**
25
+ * The color resolver for a CATEGORICAL chart, where every category is an
26
+ * identity rather than a series.
27
+ *
28
+ * Two requirements pull against each other, and both are real:
29
+ * - a DECLARED category must keep its color when a filter removes its
30
+ * neighbours (identity follows the entity, never its rank), and
31
+ * - a DRAWN category must never take the muted past-the-palette color
32
+ * just because the config also names categories this chart does not
33
+ * draw.
34
+ *
35
+ * Resolved by giving each side its own claim on the palette. A declared
36
+ * key OWNS its declared index — reserved whether or not it is drawn, which
37
+ * is what makes it stable — and undeclared drawn categories fill the slots
38
+ * no declaration claimed, in drawn order. Past the palette, or with every
39
+ * slot spoken for, a category takes the muted fold color rather than
40
+ * wrapping modulo five onto a color another category already owns.
41
+ *
42
+ * So: declare the categories whose colors must not move, and the rest sort
43
+ * themselves out.
44
+ *
45
+ * `exclude` drops the measure's own config entry (a bar chart labels its
46
+ * value column there); it is not a category and must not shift the order.
47
+ */
48
+ export function categoryColorResolver(config, categories, options = {}) {
49
+ const { exclude, otherLabel = OTHER_LABEL } = options;
50
+ const declared = Object.keys(config).filter((key) => key !== exclude && key !== otherLabel);
51
+ const slotOf = new Map();
52
+ declared.forEach((key, i) => slotOf.set(key, i));
53
+ // Whatever the declarations did not claim is free for the rest.
54
+ const claimed = new Set(slotOf.values());
55
+ const free = [];
56
+ for (let slot = 0; slot < CHART_SLOT_COUNT; slot++) {
57
+ if (!claimed.has(slot))
58
+ free.push(slot);
59
+ }
60
+ let next = 0;
61
+ for (const category of categories) {
62
+ if (category === otherLabel || slotOf.has(category))
63
+ continue;
64
+ const slot = free[next++];
65
+ slotOf.set(category, slot === undefined ? CHART_SLOT_COUNT : slot);
66
+ }
67
+ return (category) => {
68
+ const explicit = config[category]?.color;
69
+ if (explicit)
70
+ return explicit;
71
+ if (category === otherLabel)
72
+ return FOLD_COLOR;
73
+ const slot = slotOf.get(category);
74
+ if (slot === undefined || slot >= CHART_SLOT_COUNT)
75
+ return FOLD_COLOR;
76
+ return chartSlotColor(slot);
77
+ };
78
+ }
79
+ /** Resolved label for a series: explicit config first, the key itself second. */
80
+ export function seriesLabel(config, key) {
81
+ return config[key]?.label ?? key;
82
+ }
83
+ /**
84
+ * Series keys are data-column names, which allow characters a CSS custom
85
+ * ident does not (`metrics.tokens`, `model name`). Everything that writes
86
+ * or reads a per-series variable goes through this one sanitizer, so the
87
+ * two sides can never disagree.
88
+ */
89
+ export function seriesVarName(key) {
90
+ const safe = key.replace(/[^a-zA-Z0-9_-]/g, "-");
91
+ if (safe === key)
92
+ return `--chart-color-${key}`;
93
+ // Two keys may sanitize to the same ident ("metrics.tokens" vs
94
+ // "metrics tokens") — a short stable hash of the RAW key keeps them
95
+ // apart without leaking invalid characters.
96
+ let hash = 5381;
97
+ for (let i = 0; i < key.length; i++)
98
+ hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0;
99
+ return `--chart-color-${safe}-${(hash >>> 0).toString(36)}`;
100
+ }
101
+ /**
102
+ * The `var()` reference marks use for their series color.
103
+ *
104
+ * A key with no configured series resolves to an UNDEFINED custom property,
105
+ * which makes `fill` fall back to black and `stroke` to none — a silent
106
+ * black band. The fallback keeps an undeclared series visible.
107
+ */
108
+ export function seriesVar(key, fallback = chartSlotColor(0)) {
109
+ return `var(${seriesVarName(key)}, ${fallback})`;
110
+ }
111
+ /**
112
+ * The per-series CSS custom properties a `ChartContainer` injects, so chart
113
+ * marks can reference {@link seriesVar} and pick up theme changes live.
114
+ */
115
+ export function chartStyleVars(config) {
116
+ const vars = {};
117
+ for (const key of Object.keys(config)) {
118
+ vars[seriesVarName(key)] = seriesColor(config, key);
119
+ }
120
+ return vars;
121
+ }
122
+ const compactFormatter = new Intl.NumberFormat(undefined, {
123
+ notation: "compact",
124
+ maximumFractionDigits: 1,
125
+ });
126
+ /** 1029562 → "1M", 63093480 → "63.1M". For axes and stat tiles. */
127
+ export function formatCompactNumber(value) {
128
+ if (!Number.isFinite(value))
129
+ return "–";
130
+ return compactFormatter.format(value);
131
+ }
132
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
133
+ /**
134
+ * Axis label for a time bucket as the usage/statistics APIs emit them:
135
+ * `"2026-08-26"` → localized "Aug 26", an ISO datetime (hour grain) →
136
+ * localized "14:00". Anything else passes through unchanged, so categorical
137
+ * x-axes can share the same formatter.
138
+ */
139
+ export function formatBucketLabel(bucket) {
140
+ if (typeof bucket !== "string")
141
+ return String(bucket ?? "");
142
+ if (DATE_ONLY.test(bucket)) {
143
+ const d = new Date(`${bucket}T00:00:00`);
144
+ if (!Number.isNaN(d.getTime())) {
145
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
146
+ }
147
+ }
148
+ const asDate = new Date(bucket);
149
+ if (bucket.includes("T") && !Number.isNaN(asDate.getTime())) {
150
+ return asDate.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
151
+ }
152
+ return bucket;
153
+ }
154
+ /**
155
+ * Fold a category list's tail into a single "Other" row once it exceeds
156
+ * `max` rows — the discipline for categorical charts: a 9th slice is never
157
+ * a 9th color.
158
+ *
159
+ * Ranks by value FIRST. The fold keeps the head, so on an unranked list it
160
+ * would swallow whatever happened to be sorted late — a donut of six
161
+ * equal-ish categories plus one dominant one at the end rendered as four
162
+ * slivers and an unlabeled 96% "Other". Ranking here rather than trusting
163
+ * callers makes that unreachable, including for charts a builder agent
164
+ * writes.
165
+ */
166
+ export function foldCategoryTail(rows, options) {
167
+ const { categoryKey, valueKey, otherLabel = OTHER_LABEL } = options;
168
+ // A cap below 2 cannot hold a fold (one real row + "Other" is the floor);
169
+ // a non-finite cap would fold everything into a single bucket.
170
+ const requested = options.max ?? CHART_SLOT_COUNT;
171
+ const max = Math.max(2, Number.isFinite(requested) ? requested : CHART_SLOT_COUNT);
172
+ if (rows.length <= max)
173
+ return rows;
174
+ const ranked = [...rows].sort((a, b) => (Number(b[valueKey]) || 0) - (Number(a[valueKey]) || 0));
175
+ // A pre-existing "Other" row always joins the fold, so set it aside
176
+ // BEFORE taking the head: leaving it there would cost a real category
177
+ // its place for a row that gets folded away anyway.
178
+ const existingOther = ranked.filter((row) => String(row[categoryKey]) === otherLabel);
179
+ const real = ranked.filter((row) => String(row[categoryKey]) !== otherLabel);
180
+ const kept = real.slice(0, max - 1);
181
+ const folded = [...existingOther, ...real.slice(max - 1)];
182
+ if (!folded.length)
183
+ return kept;
184
+ const otherTotal = folded.reduce((sum, row) => sum + (Number(row[valueKey]) || 0), 0);
185
+ const other = { ...folded[0], [categoryKey]: otherLabel, [valueKey]: otherTotal };
186
+ return [...kept, other];
187
+ }
@@ -0,0 +1,77 @@
1
+ import * as React from "react";
2
+ import * as RechartsPrimitive from "recharts";
3
+ import { type ChartConfig } from "./chart-config.js";
4
+ /**
5
+ * The shadcn-style chart layer over recharts: `ChartContainer` injects one
6
+ * `--chart-color-<key>` CSS variable per configured series (resolved from the
7
+ * theme's `--chart-1..5` slots unless overridden), so every mark inside can
8
+ * reference its series color with `seriesVar(key)` and follow theme
9
+ * changes — light/dark and tenant themes alike — without re-rendering logic.
10
+ *
11
+ * Tooltip and legend CONTENT components read the same config through
12
+ * context, so a series is named once and every surface agrees.
13
+ */
14
+ interface ChartContextValue {
15
+ config: ChartConfig;
16
+ }
17
+ export declare function useChart(): ChartContextValue;
18
+ export interface ChartContainerProps extends React.ComponentProps<"div"> {
19
+ config: ChartConfig;
20
+ /**
21
+ * When false, children render without a ResponsiveContainer — for fixed
22
+ * pixel sizes (the chart element then carries width/height itself), and
23
+ * for test environments where layout measurement never settles.
24
+ */
25
+ responsive?: boolean;
26
+ /** Exactly one chart element — ResponsiveContainer accepts a single child. */
27
+ children: React.ReactElement;
28
+ }
29
+ declare const ChartContainer: React.ForwardRefExoticComponent<Omit<ChartContainerProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
30
+ /** Recharts' Tooltip/Legend, re-exported so hosts never import recharts. */
31
+ declare const ChartTooltip: typeof RechartsPrimitive.Tooltip;
32
+ declare const ChartLegend: React.MemoExoticComponent<(outsideProps: RechartsPrimitive.LegendProps) => React.ReactPortal | null>;
33
+ type TooltipPayloadEntry = {
34
+ dataKey?: string | number;
35
+ name?: string | number;
36
+ value?: number | string;
37
+ color?: string;
38
+ payload?: Record<string, unknown>;
39
+ };
40
+ export interface ChartTooltipContentProps {
41
+ active?: boolean;
42
+ label?: unknown;
43
+ payload?: TooltipPayloadEntry[];
44
+ className?: string;
45
+ /** Format the header (usually the x value). Defaults to `String(label)`. */
46
+ labelFormatter?: (label: unknown) => React.ReactNode;
47
+ /** Format each row's value. Defaults to `toLocaleString`. */
48
+ valueFormatter?: (value: number) => React.ReactNode;
49
+ /** Hide the color indicator squares. */
50
+ hideIndicator?: boolean;
51
+ /** Drop the header row entirely (a donut names its slice on the row). */
52
+ hideLabel?: boolean;
53
+ /**
54
+ * Override the indicator color per row — for charts whose CELLS recolor
55
+ * the marks (colorByCategory) while the payload still carries the parent
56
+ * series color.
57
+ */
58
+ indicatorColor?: (entryKey: string, label: unknown) => string | undefined;
59
+ }
60
+ /**
61
+ * The hover layer's content. Rows are named from the chart config (the
62
+ * series key is a data column name — never show it raw when a label exists).
63
+ */
64
+ declare function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator, hideLabel, indicatorColor, }: ChartTooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
65
+ export interface ChartLegendContentProps {
66
+ payload?: Array<{
67
+ dataKey?: string | number;
68
+ value?: unknown;
69
+ color?: string;
70
+ }>;
71
+ className?: string;
72
+ }
73
+ /** Legend rows named from the chart config, color chips from the payload. */
74
+ declare function ChartLegendContent({ payload, className }: ChartLegendContentProps): import("react/jsx-runtime").JSX.Element | null;
75
+ /** Compact y-axis tick formatter shared by the chart wrappers. */
76
+ export declare function compactAxisTick(value: unknown): string;
77
+ export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent };
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import * as RechartsPrimitive from "recharts";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+ import { chartStyleVars, formatCompactNumber, seriesLabel, seriesVar, } from "./chart-config.js";
6
+ const ChartContext = React.createContext(null);
7
+ export function useChart() {
8
+ const ctx = React.useContext(ChartContext);
9
+ if (!ctx) {
10
+ throw new Error("useChart must be used inside a <ChartContainer>");
11
+ }
12
+ return ctx;
13
+ }
14
+ const ChartContainer = React.forwardRef(({ config, responsive = true, className, children, style, ...props }, ref) => {
15
+ const vars = React.useMemo(() => chartStyleVars(config), [config]);
16
+ const contextValue = React.useMemo(() => ({ config }), [config]);
17
+ return (_jsx(ChartContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, "data-chart": "", className: cn("flex w-full justify-center text-xs",
18
+ // A fixed-size chart carries its own dimensions; forcing 16:9
19
+ // on it would fight them.
20
+ responsive && "aspect-video", "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground", "[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/60", "[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border", "[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted/50", "[&_.recharts-reference-line_line]:stroke-border", "[&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", "[&_.recharts-layer]:outline-none", className), style: { ...vars, ...style }, ...props, children: responsive ? (_jsx(RechartsPrimitive.ResponsiveContainer, { width: "100%", height: "100%", children: children })) : (children) }) }));
21
+ });
22
+ ChartContainer.displayName = "ChartContainer";
23
+ /** Recharts' Tooltip/Legend, re-exported so hosts never import recharts. */
24
+ const ChartTooltip = RechartsPrimitive.Tooltip;
25
+ const ChartLegend = RechartsPrimitive.Legend;
26
+ /**
27
+ * The hover layer's content. Rows are named from the chart config (the
28
+ * series key is a data column name — never show it raw when a label exists).
29
+ */
30
+ function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator = false, hideLabel = false, indicatorColor, }) {
31
+ const { config } = useChart();
32
+ if (!active || !payload?.length)
33
+ return null;
34
+ return (_jsxs("div", { className: cn("min-w-[9rem] rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-md", className), children: [!hideLabel && (_jsx("div", { className: "mb-1.5 font-medium text-foreground", children: labelFormatter ? labelFormatter(label) : String(label ?? "") })), _jsx("div", { className: "grid gap-1", children: payload.map((entry, i) => {
35
+ // `name` first: a Pie names entries by its nameKey (the category),
36
+ // while its dataKey is the measure column — the one label a donut
37
+ // row must NOT wear. Series charts set name = dataKey, so nothing
38
+ // changes for them.
39
+ const key = String(entry.name ?? entry.dataKey ?? i);
40
+ const numeric = typeof entry.value === "number" ? entry.value : Number(entry.value);
41
+ const finite = Number.isFinite(numeric);
42
+ return (_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-muted-foreground", children: [!hideIndicator && (_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: {
43
+ background: indicatorColor?.(key, label) ?? entry.color ?? seriesVar(key),
44
+ } })), _jsx("span", { children: seriesLabel(config, key) })] }), _jsx("span", { className: "font-mono font-medium tabular-nums text-foreground", children: finite && valueFormatter
45
+ ? valueFormatter(numeric)
46
+ : finite
47
+ ? numeric.toLocaleString()
48
+ : String(entry.value ?? "") })] }, `${i}-${key}`));
49
+ }) })] }));
50
+ }
51
+ /** Legend rows named from the chart config, color chips from the payload. */
52
+ function ChartLegendContent({ payload, className }) {
53
+ const { config } = useChart();
54
+ if (!payload?.length)
55
+ return null;
56
+ return (_jsx("div", { className: cn("flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3", className), children: payload.map((entry, i) => {
57
+ const key = String(entry.dataKey ?? entry.value ?? i);
58
+ return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: entry.color ?? seriesVar(key) } }), _jsx("span", { children: seriesLabel(config, key) })] }, `${i}-${key}`));
59
+ }) }));
60
+ }
61
+ /** Compact y-axis tick formatter shared by the chart wrappers. */
62
+ export function compactAxisTick(value) {
63
+ const n = typeof value === "number" ? value : Number(value);
64
+ return Number.isFinite(n) ? formatCompactNumber(n) : String(value ?? "");
65
+ }
66
+ export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent };
@@ -0,0 +1,30 @@
1
+ import * as React from "react";
2
+ import { type ChartConfig } from "./chart-config.js";
3
+ /**
4
+ * Share of a whole ("tokens per model"). Slices are identities, so each
5
+ * takes a theme slot resolved over the DRAWN slices — declared config keys
6
+ * first, the rest in drawn order — and there are only five slots,
7
+ * so `maxSlices` folds the tail into a muted "Other" (ranked first, so the
8
+ * fold can never swallow the largest slice) instead of inventing colors.
9
+ * The center carries the headline total; the legend carries names + shares,
10
+ * so the hover layer is confirmation, not the only way to read the chart.
11
+ */
12
+ export interface DonutChartProps {
13
+ data: Array<Record<string, unknown>>;
14
+ categoryKey: string;
15
+ valueKey: string;
16
+ /** Per-category label/color overrides, keyed by category value. */
17
+ config?: ChartConfig;
18
+ /** Fold categories beyond this count into "Other". Default 5. */
19
+ maxSlices?: number;
20
+ centerLabel?: React.ReactNode;
21
+ /** Defaults to the compact-formatted sum of all slices. */
22
+ centerValue?: React.ReactNode;
23
+ height?: number;
24
+ /** Fixed pixel width opts out of responsive measurement (tests, embeds). */
25
+ width?: number;
26
+ valueFormatter?: (value: number) => React.ReactNode;
27
+ showLegend?: boolean;
28
+ className?: string;
29
+ }
30
+ export declare function DonutChart({ data, categoryKey, valueKey, config, maxSlices, centerLabel, centerValue, height, width, valueFormatter, showLegend, className, }: DonutChartProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import * as RechartsPrimitive from "recharts";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+ import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart.js";
6
+ import { CHART_SLOT_COUNT, categoryColorResolver, foldCategoryTail, formatCompactNumber, } from "./chart-config.js";
7
+ const EMPTY_CONFIG = {};
8
+ export function DonutChart({ data, categoryKey, valueKey, config = EMPTY_CONFIG, maxSlices = 5, centerLabel, centerValue, height = 240, width, valueFormatter, showLegend = true, className, }) {
9
+ // The donut's slices are identities and the palette has five slots —
10
+ // a cap above that would wrap colors modulo five.
11
+ const cappedSlices = Math.min(CHART_SLOT_COUNT, maxSlices);
12
+ // One expression for "this row's category", so the color resolver and
13
+ // the rendered cell can never disagree about a missing value.
14
+ const categoryOf = React.useCallback((row, i) => String(row[categoryKey] ?? i), [categoryKey]);
15
+ const rows = React.useMemo(() => foldCategoryTail(data, { categoryKey, valueKey, max: cappedSlices }), [data, categoryKey, valueKey, cappedSlices]);
16
+ const total = React.useMemo(() => rows.reduce((sum, row) => {
17
+ const value = Number(row[valueKey]);
18
+ return sum + (Number.isFinite(value) ? value : 0);
19
+ }, 0), [rows, valueKey]);
20
+ // Identity resolved over the DRAWN slices: the fold ranks, so a category
21
+ // late in the input can be promoted, and resolving from input order would
22
+ // paint that visible slice the muted past-the-palette grey.
23
+ const sliceColor = React.useMemo(() => categoryColorResolver(config, rows.map((row) => String(row[categoryKey] ?? ""))), [config, rows, categoryOf]);
24
+ const fixed = width !== undefined;
25
+ const sliceLabel = (category) => config[category]?.label ?? category;
26
+ return (_jsxs("div", { className: cn("flex flex-col", className), children: [_jsxs("div", { className: "relative", children: [_jsx(ChartContainer, { config: config, responsive: !fixed, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.PieChart, { width: width, height: fixed ? height : undefined, children: [_jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { hideLabel: true, valueFormatter: valueFormatter }) }), _jsx(RechartsPrimitive.Pie, { data: rows, dataKey: valueKey, nameKey: categoryKey, innerRadius: "62%", outerRadius: "88%", paddingAngle: 2, strokeWidth: 0, isAnimationActive: false, children: rows.map((row, i) => {
27
+ const category = String(row[categoryKey] ?? i);
28
+ return (_jsx(RechartsPrimitive.Cell, { fill: sliceColor(category) }, `${i}-${category}`));
29
+ }) })] }) }), _jsxs("div", { className: "pointer-events-none absolute inset-0 flex flex-col items-center justify-center", children: [_jsx("span", { className: "text-xl font-semibold tabular-nums", "data-donut-center": "", children: centerValue ?? formatCompactNumber(total) }), centerLabel && _jsx("span", { className: "text-[11px] text-muted-foreground", children: centerLabel })] })] }), showLegend && (_jsx("div", { className: "flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3", children: rows.map((row, i) => {
30
+ const category = String(row[categoryKey] ?? i);
31
+ const raw = Number(row[valueKey]);
32
+ const value = Number.isFinite(raw) ? raw : 0;
33
+ const pct = total > 0 ? (value / total) * 100 : 0;
34
+ // A real slice must never read as absent: below 1% says so.
35
+ const share = pct > 0 && pct < 1 ? "<1" : String(Math.round(pct));
36
+ return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: sliceColor(category) } }), _jsx("span", { children: sliceLabel(category) }), _jsxs("span", { className: "tabular-nums", children: [share, "%"] })] }, `${i}-${category}`));
37
+ }) }))] }));
38
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+ /**
3
+ * The KPI tile: one number that matters, with its trend. Dashboards lead
4
+ * with a row of these — the primary figure top-left, everything else
5
+ * subordinate — so the card keeps its own hierarchy strict: label small and
6
+ * muted, value dominant, delta as a badge that carries the ONLY color.
7
+ */
8
+ export interface StatCardProps extends Omit<React.ComponentProps<"div">, "children"> {
9
+ label: React.ReactNode;
10
+ /** The number itself. Strings render verbatim (pre-formatted values). */
11
+ value: number | string | null | undefined;
12
+ /** "compact" → 1.03M; "plain" → 1,029,562. Ignored for string values. */
13
+ format?: "compact" | "plain";
14
+ /**
15
+ * Percent change vs the comparison period, e.g. +12.4. Sign picks the
16
+ * badge color; `invertDelta` flips which sign reads as good (error rates,
17
+ * costs). Omit when there is no meaningful comparison — a fabricated
18
+ * trend is worse than none.
19
+ */
20
+ delta?: number;
21
+ /** What the delta compares against, e.g. "vs previous 30 days". */
22
+ deltaLabel?: React.ReactNode;
23
+ /** Lower-is-better metrics: a negative delta renders as good. */
24
+ invertDelta?: boolean;
25
+ /** Small print under the value (e.g. "63.1 MB across 11 files"). */
26
+ hint?: React.ReactNode;
27
+ icon?: React.ReactNode;
28
+ /** Render a loading skeleton instead of the value. */
29
+ loading?: boolean;
30
+ }
31
+ declare const StatCard: React.ForwardRefExoticComponent<Omit<StatCardProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
32
+ export { StatCard };
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+ import { formatCompactNumber } from "./chart-config.js";
5
+ function formatValue(value, format) {
6
+ if (value === null || value === undefined)
7
+ return "–";
8
+ if (typeof value === "string")
9
+ return value;
10
+ // Both branches take the same guard: a NaN must not render as "NaN" in
11
+ // one format and "–" in the other.
12
+ if (!Number.isFinite(value))
13
+ return "–";
14
+ return format === "plain" ? value.toLocaleString() : formatCompactNumber(value);
15
+ }
16
+ const StatCard = React.forwardRef(({ label, value, format = "compact", delta, deltaLabel, invertDelta = false, hint, icon, loading = false, className, ...props }, ref) => {
17
+ // A delta of Infinity is the natural result of dividing by a zero
18
+ // previous period, and "▲ ∞%" styled as GOOD is worse than no badge.
19
+ const hasDelta = delta !== undefined && Number.isFinite(delta);
20
+ const direction = !hasDelta || delta === 0 ? "flat" : delta > 0 ? "up" : "down";
21
+ const good = delta !== undefined && delta !== 0 && (invertDelta ? delta < 0 : delta > 0);
22
+ return (_jsxs("div", { ref: ref, "data-stat-card": "", className: cn("rounded-xl border border-border bg-card p-4 text-card-foreground", className), ...props, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate text-xs font-medium text-muted-foreground", children: label }), icon && _jsx("span", { className: "shrink-0 text-muted-foreground [&_svg]:size-4", children: icon })] }), _jsxs("div", { className: "mt-2 flex items-baseline gap-2", children: [loading ? (_jsx("span", { className: "h-7 w-20 animate-pulse rounded-md bg-muted", "data-stat-skeleton": "" })) : (_jsx("span", { className: "text-2xl font-semibold tabular-nums tracking-tight", "data-stat-value": "", children: formatValue(value, format) })), !loading && hasDelta && (_jsxs("span", { "data-stat-delta": direction === "flat" ? "flat" : good ? "good" : "bad", className: cn("inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium tabular-nums", direction === "flat"
23
+ ? "bg-muted text-muted-foreground"
24
+ : good
25
+ ? "bg-success/10 text-success"
26
+ : "bg-destructive/10 text-destructive"), children: [direction !== "flat" && _jsx("span", { "aria-hidden": true, children: direction === "up" ? "▲" : "▼" }), _jsxs("span", { className: "sr-only", children: [direction === "flat" ? "unchanged" : direction, " "] }), Math.abs(delta).toLocaleString(undefined, { maximumFractionDigits: 1 }), "%"] }))] }), (hint || deltaLabel) && (_jsxs("div", { className: "mt-1 truncate text-[11px] text-muted-foreground", children: [hint, hint && deltaLabel ? " · " : null, deltaLabel] }))] }));
27
+ });
28
+ StatCard.displayName = "StatCard";
29
+ export { StatCard };
@@ -0,0 +1,42 @@
1
+ import * as React from "react";
2
+ import { type ChartConfig } from "./chart-config.js";
3
+ /**
4
+ * Change over time — the dashboard's workhorse. Declarative enough that a
5
+ * renderer (or the builder agent) states WHAT to plot and nothing else:
6
+ *
7
+ * <TimeSeriesChart
8
+ * data={rows} xKey="bucket"
9
+ * config={{ chat: { label: "Chat" }, workflow: { label: "Workflows" } }}
10
+ * kind="area" stacked
11
+ * />
12
+ *
13
+ * Series colors come from the theme's categorical slots in config order;
14
+ * the hover layer (crosshair + tooltip) is on by default; one y-axis, ever.
15
+ */
16
+ export interface TimeSeriesChartProps {
17
+ data: Array<Record<string, unknown>>;
18
+ /** Series semantics — keys must match data columns. Order = color order. */
19
+ config: ChartConfig;
20
+ /** The x column. Defaults to `"bucket"` (the usage APIs' column). */
21
+ xKey?: string;
22
+ /** Which config keys to draw. Defaults to every key in `config`. */
23
+ series?: string[];
24
+ kind?: "area" | "line" | "bar";
25
+ /**
26
+ * Stack the series (area/bar). A LINE chart has no stacked form —
27
+ * recharts' Line does not stack — so for `kind="line"` this prop is
28
+ * ignored; use `kind="area"` when the total matters.
29
+ */
30
+ stacked?: boolean;
31
+ /** Pixel height of the plot (the container spans full width). */
32
+ height?: number;
33
+ /** Fixed pixel width opts out of responsive measurement (tests, embeds). */
34
+ width?: number;
35
+ valueFormatter?: (value: number) => React.ReactNode;
36
+ /** X tick + tooltip header formatter. Defaults to bucket-aware dates. */
37
+ xTickFormatter?: (value: unknown) => string;
38
+ /** Defaults to true when more than one series is drawn. */
39
+ showLegend?: boolean;
40
+ className?: string;
41
+ }
42
+ export declare function TimeSeriesChart({ data, config, xKey, series, kind, stacked, height, width, valueFormatter, xTickFormatter, showLegend, className, }: TimeSeriesChartProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as RechartsPrimitive from "recharts";
3
+ import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, compactAxisTick, } from "./chart.js";
4
+ import { chartSlotColor, formatBucketLabel, seriesVar } from "./chart-config.js";
5
+ export function TimeSeriesChart({ data, config, xKey = "bucket", series, kind = "area", stacked = false, height = 240, width, valueFormatter, xTickFormatter = formatBucketLabel, showLegend, className, }) {
6
+ const keys = series ?? Object.keys(config);
7
+ // A drawn key with no config takes the next slot AFTER the configured
8
+ // ones — the rendered index would collide with a configured series when
9
+ // an undeclared key is drawn first.
10
+ const configured = Object.keys(config);
11
+ const fallbackSlot = (key) => {
12
+ const declared = configured.indexOf(key);
13
+ if (declared !== -1)
14
+ return declared;
15
+ const undeclared = keys.filter((k) => !configured.includes(k)).indexOf(key);
16
+ return configured.length + Math.max(0, undeclared);
17
+ };
18
+ const legend = showLegend ?? keys.length > 1;
19
+ const fixed = width !== undefined;
20
+ const ChartRoot = kind === "line"
21
+ ? RechartsPrimitive.LineChart
22
+ : kind === "bar"
23
+ ? RechartsPrimitive.BarChart
24
+ : RechartsPrimitive.AreaChart;
25
+ return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(ChartRoot, { data: data, width: width, height: fixed ? height : undefined, margin: { top: 4, right: 8, bottom: 0, left: 0 }, children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: xKey, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 24, tickFormatter: xTickFormatter }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: xTickFormatter, valueFormatter: valueFormatter }) }), legend && _jsx(ChartLegend, { content: _jsx(ChartLegendContent, {}) }), keys.map((key) => kind === "line" ? (_jsx(RechartsPrimitive.Line, { type: "monotone", dataKey: key, stroke: seriesVar(key, chartSlotColor(fallbackSlot(key))), strokeWidth: 2, dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)) : kind === "bar" ? (_jsx(RechartsPrimitive.Bar, { dataKey: key, stackId: stacked ? "stack" : undefined, fill: seriesVar(key, chartSlotColor(fallbackSlot(key))), maxBarSize: 48, radius: stacked ? 0 : [4, 4, 0, 0], isAnimationActive: false }, key)) : (_jsx(RechartsPrimitive.Area, { type: "monotone", dataKey: key, stackId: stacked ? "stack" : undefined, stroke: seriesVar(key, chartSlotColor(fallbackSlot(key))), fill: seriesVar(key, chartSlotColor(fallbackSlot(key))), fillOpacity: 0.25, strokeWidth: 2,
26
+ // A one-or-two point window would otherwise draw a floating
27
+ // speck; dots make sparse data read as sparse, not broken.
28
+ dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)))] }) }));
29
+ }
@@ -47,8 +47,8 @@ function currentlyOurs(root, name) {
47
47
  const written = rootThemeWritten.get(name);
48
48
  if (written === undefined)
49
49
  return true;
50
- return (root.style.getPropertyValue(name) === written.value
51
- && root.style.getPropertyPriority(name) === written.priority);
50
+ return (root.style.getPropertyValue(name) === written.value &&
51
+ root.style.getPropertyPriority(name) === written.priority);
52
52
  }
53
53
  function rememberWrite(root, name) {
54
54
  // Read back rather than storing what we passed: the browser normalises the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "license": "MIT",
5
5
  "description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
6
6
  "keywords": [
@@ -27,6 +27,10 @@
27
27
  "types": "./dist/index.d.ts",
28
28
  "import": "./dist/index.js"
29
29
  },
30
+ "./chart": {
31
+ "types": "./dist/chart.d.ts",
32
+ "import": "./dist/chart.js"
33
+ },
30
34
  "./styles.css": "./dist/styles.css"
31
35
  },
32
36
  "files": [
@@ -38,16 +42,16 @@
38
42
  "access": "public"
39
43
  },
40
44
  "peerDependencies": {
41
- "react": "^19.0.0",
42
- "react-dom": "^19.0.0",
43
- "react-router": "^7.0.0",
44
- "@assistant-ui/react": "^0.15.1",
45
45
  "@ag-ui/client": "^0.0.52",
46
46
  "@ag-ui/core": "^0.0.52",
47
- "zustand": "^5.0.0",
48
- "lucide-react": ">=0.400.0",
47
+ "@assistant-ui/react": "^0.15.1",
49
48
  "@azure/msal-browser": "^5.0.0",
50
- "@azure/msal-react": "^5.0.0"
49
+ "@azure/msal-react": "^5.0.0",
50
+ "lucide-react": ">=0.400.0",
51
+ "react": "^19.0.0",
52
+ "react-dom": "^19.0.0",
53
+ "react-router": "^7.0.0",
54
+ "zustand": "^5.0.0"
51
55
  },
52
56
  "peerDependenciesMeta": {
53
57
  "@azure/msal-browser": {
@@ -69,10 +73,11 @@
69
73
  "class-variance-authority": "^0.7.0",
70
74
  "clsx": "^2.1.0",
71
75
  "react-markdown": "^10.0.0",
76
+ "recharts": "^3.10.1",
72
77
  "remark-gfm": "^4.0.0",
73
78
  "tailwind-merge": "^3.5.0",
74
- "@iloveagents/foundry-agent": "^0.22.0",
75
- "@iloveagents/foundry-web-primitives": "^0.22.0"
79
+ "@iloveagents/foundry-agent": "^0.24.0",
80
+ "@iloveagents/foundry-web-primitives": "^0.24.0"
76
81
  },
77
82
  "devDependencies": {
78
83
  "@ag-ui/client": "^0.0.52",