@iloveagents/foundry-web-ui 0.21.1 → 0.23.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.
- package/dist/chart.d.ts +15 -0
- package/dist/chart.js +15 -0
- package/dist/components/app-brand.d.ts +3 -13
- package/dist/components/app-brand.js +50 -2
- package/dist/components/chart/category-bar-chart.d.ts +31 -0
- package/dist/components/chart/category-bar-chart.js +43 -0
- package/dist/components/chart/chart-card.d.ts +27 -0
- package/dist/components/chart/chart-card.js +23 -0
- package/dist/components/chart/chart-config.d.ts +75 -0
- package/dist/components/chart/chart-config.js +114 -0
- package/dist/components/chart/chart.d.ts +75 -0
- package/dist/components/chart/chart.js +66 -0
- package/dist/components/chart/donut-chart.d.ts +29 -0
- package/dist/components/chart/donut-chart.js +37 -0
- package/dist/components/chart/stat-card.d.ts +32 -0
- package/dist/components/chart/stat-card.js +22 -0
- package/dist/components/chart/time-series-chart.d.ts +42 -0
- package/dist/components/chart/time-series-chart.js +18 -0
- package/dist/components/sidebar.js +9 -9
- package/dist/components/theme-runtime-provider.js +2 -2
- package/dist/lib/theme-runtime.d.ts +25 -0
- package/dist/lib/theme-runtime.js +6 -0
- package/dist/styles.css +3 -0
- package/package.json +15 -10
package/dist/chart.d.ts
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, type ChartContainerProps, type ChartTooltipContentProps, type ChartLegendContentProps, } from "./components/chart/chart.js";
|
|
10
|
+
export { 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 { 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";
|
|
@@ -6,20 +6,10 @@ export interface BrandLockup {
|
|
|
6
6
|
isIcon: boolean;
|
|
7
7
|
name: string;
|
|
8
8
|
showName: boolean;
|
|
9
|
+
/** Validated CSS height for the mark, or undefined to use the default. */
|
|
10
|
+
height?: string;
|
|
9
11
|
}
|
|
10
|
-
|
|
11
|
-
* Decide what the brand lockup renders. Exported (underscore-prefixed) so the
|
|
12
|
-
* branching is unit-testable without a DOM renderer — same approach as
|
|
13
|
-
* ``tool-fallback.tsx``.
|
|
14
|
-
*
|
|
15
|
-
* The rule worth stating: whether the name is printed depends on the *shape*
|
|
16
|
-
* of the brand image. A wordmark already spells the brand out, so pairing it
|
|
17
|
-
* with `appName` stutters ("Acme Acme"). An icon says nothing on its
|
|
18
|
-
* own, so it needs the name to complete the lockup. Hence an icon wins over a
|
|
19
|
-
* wordmark when both are set — supplying an icon is an explicit request for
|
|
20
|
-
* "<mark> Product Name", which is what a tenant whose product name differs
|
|
21
|
-
* from their company name is asking for.
|
|
22
|
-
*/
|
|
12
|
+
export declare function _safeLogoHeight(value: string | undefined): string | undefined;
|
|
23
13
|
export declare function _resolveBrandLockup(branding: ThemeBranding, mode: "light" | "dark", showName: boolean): BrandLockup;
|
|
24
14
|
export declare function AppBrand({ showName, className, iconClassName, labelClassName, }: {
|
|
25
15
|
showName?: boolean;
|
|
@@ -15,6 +15,37 @@ import { useThemeRuntime } from "./theme-runtime-provider.js";
|
|
|
15
15
|
* "<mark> Product Name", which is what a tenant whose product name differs
|
|
16
16
|
* from their company name is asking for.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* A tenant-supplied length, or undefined.
|
|
20
|
+
*
|
|
21
|
+
* The value lands in an inline `style`, and a theme is content a tenant
|
|
22
|
+
* authors — so this allowlists a shape rather than trying to spot bad ones.
|
|
23
|
+
* Anything else (a `calc()`, a `var()`, a url, a second declaration smuggled
|
|
24
|
+
* in behind a `;`) fails the pattern and the caller falls back to the default
|
|
25
|
+
* height, which is always a safe thing to render.
|
|
26
|
+
*/
|
|
27
|
+
//: The tallest mark the header will render. A syntactically valid length is
|
|
28
|
+
//: not a SAFE one: `logoHeight: "320px"` is a plausible typo, and because the
|
|
29
|
+
//: mark is `shrink-0` it would push the sidebar and mobile-header actions off
|
|
30
|
+
//: screen rather than shrinking. Marks above this are clamped, not rejected —
|
|
31
|
+
//: a too-large value should look wrong, not vanish.
|
|
32
|
+
const MAX_LOGO_HEIGHT_REM = 4;
|
|
33
|
+
const REM_PER_UNIT = { rem: 1, em: 1, px: 1 / 16 };
|
|
34
|
+
export function _safeLogoHeight(value) {
|
|
35
|
+
if (!value)
|
|
36
|
+
return undefined;
|
|
37
|
+
// The integer part is optional: `.5rem` is a valid CSS length and a common
|
|
38
|
+
// spelling, and silently falling back to the default for it would make the
|
|
39
|
+
// contract a lie.
|
|
40
|
+
const match = /^(\d*\.?\d+)(px|rem|em)$/.exec(value.trim());
|
|
41
|
+
if (!match)
|
|
42
|
+
return undefined;
|
|
43
|
+
const [, rawNumber, unit] = match;
|
|
44
|
+
const asRem = Number(rawNumber) * REM_PER_UNIT[unit];
|
|
45
|
+
if (!Number.isFinite(asRem) || asRem <= 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
return asRem > MAX_LOGO_HEIGHT_REM ? `${MAX_LOGO_HEIGHT_REM}rem` : value.trim();
|
|
48
|
+
}
|
|
18
49
|
export function _resolveBrandLockup(branding, mode, showName) {
|
|
19
50
|
const isDark = mode === "dark";
|
|
20
51
|
const pick = (dark, light) => (isDark ? dark || light : light || dark);
|
|
@@ -26,12 +57,18 @@ export function _resolveBrandLockup(branding, mode, showName) {
|
|
|
26
57
|
isIcon: Boolean(iconUrl),
|
|
27
58
|
name: branding.appName || "Foundry UI",
|
|
28
59
|
showName: showName && (Boolean(iconUrl) || !markUrl),
|
|
60
|
+
height: _safeLogoHeight(branding.logoHeight),
|
|
29
61
|
};
|
|
30
62
|
}
|
|
31
63
|
export function AppBrand({ showName = true, className, iconClassName, labelClassName, }) {
|
|
32
64
|
const runtime = useThemeRuntime();
|
|
33
65
|
const lockup = _resolveBrandLockup(runtime.branding, runtime.mode, showName);
|
|
34
|
-
return (_jsxs("span", { className: cn("flex items-center gap-2", className), children: [lockup.markUrl ? (_jsx("img", { src: lockup.markUrl, alt: lockup.name, className: cn("
|
|
66
|
+
return (_jsxs("span", { className: cn("flex items-center gap-2", className), children: [lockup.markUrl ? (_jsx("img", { src: lockup.markUrl, alt: lockup.name, style: lockup.height ? { height: lockup.height } : undefined, className: cn("object-contain",
|
|
67
|
+
// A custom mark must be able to SHRINK. `shrink-0` is right for the
|
|
68
|
+
// 20px default, whose max-widths already fit every header; for a
|
|
69
|
+
// taller one it is the thing that pushes the collapse button out
|
|
70
|
+
// of the sidebar instead of letting the image narrow.
|
|
71
|
+
lockup.height ? "min-w-0 shrink" : "shrink-0",
|
|
35
72
|
// Both lock HEIGHT and let width follow the aspect ratio. Sizing an
|
|
36
73
|
// icon into a square box (`size-5`) silently shrinks any mark that
|
|
37
74
|
// is not 1:1 — `object-contain` letterboxes a 4:3 glyph to 20x15,
|
|
@@ -39,5 +76,16 @@ export function AppBrand({ showName = true, className, iconClassName, labelClass
|
|
|
39
76
|
// replaced. Marks are rarely square, so height is the only
|
|
40
77
|
// dimension worth fixing. The max-width is just a runaway guard:
|
|
41
78
|
// tighter for an icon, which has to leave room for the name.
|
|
42
|
-
|
|
79
|
+
// `branding.logoHeight` overrides the HEIGHT, and the width is
|
|
80
|
+
// bounded by the CONTAINER rather than by a number picked here.
|
|
81
|
+
// The 7rem cap is sized for a 20px mark and would letterbox a
|
|
82
|
+
// taller one; a bigger fixed cap is just a different wrong number
|
|
83
|
+
// — 20rem is 320px while an expanded sidebar is 248px, so a 4:1
|
|
84
|
+
// wordmark at the 4rem ceiling (256px) still overflowed it.
|
|
85
|
+
// `max-w-full` is whatever the header actually has.
|
|
86
|
+
lockup.height
|
|
87
|
+
? "w-auto max-w-full"
|
|
88
|
+
: lockup.isIcon
|
|
89
|
+
? "h-5 w-auto max-w-8"
|
|
90
|
+
: "h-5 w-auto max-w-[7rem]", iconClassName) })) : (_jsx(Layers3, { className: cn("size-5 shrink-0 text-primary", iconClassName) })), lockup.showName && _jsx("span", { className: cn("truncate", labelClassName), children: lockup.name })] }));
|
|
43
91
|
}
|
|
@@ -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,43 @@
|
|
|
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, chartSlotColor, foldCategoryTail, seriesColor, } from "./chart-config.js";
|
|
6
|
+
export function CategoryBarChart({ data, categoryKey, valueKey, config = {}, horizontal = true, maxBars, colorByCategory = false, height, width, valueFormatter, categoryFormatter, className, }) {
|
|
7
|
+
// Identity coloring caps at the palette (5 + Other); a single-hue ranked
|
|
8
|
+
// list caps at what stays readable. Both fold rather than overflow.
|
|
9
|
+
const effectiveMax = maxBars ?? (colorByCategory ? CHART_SLOT_COUNT : 12);
|
|
10
|
+
const rows = React.useMemo(() => {
|
|
11
|
+
// This is a RANKED breakdown by contract, and the fold keeps the head —
|
|
12
|
+
// rank first so an unsorted input never folds its biggest categories.
|
|
13
|
+
const ranked = [...data].sort((a, b) => (Number(b[valueKey]) || 0) - (Number(a[valueKey]) || 0));
|
|
14
|
+
return foldCategoryTail(ranked, { categoryKey, valueKey, max: effectiveMax });
|
|
15
|
+
}, [data, categoryKey, valueKey, effectiveMax]);
|
|
16
|
+
// Identity follows the category's position in the ORIGINAL data (or its
|
|
17
|
+
// explicit config color) — filtering/sorting must not repaint survivors.
|
|
18
|
+
const categoryColor = React.useCallback((category) => {
|
|
19
|
+
const explicit = config[category]?.color;
|
|
20
|
+
if (explicit)
|
|
21
|
+
return explicit;
|
|
22
|
+
// Declared identity first: a category's position in the CONFIG is
|
|
23
|
+
// immune to the caller filtering the data. Data order is only the
|
|
24
|
+
// fallback for undeclared categories.
|
|
25
|
+
// The measure's own entry (labels/colors for the value column) is
|
|
26
|
+
// not a category — it must not shift the identity order.
|
|
27
|
+
const configIndex = Object.keys(config)
|
|
28
|
+
.filter((key) => key !== valueKey)
|
|
29
|
+
.indexOf(category);
|
|
30
|
+
if (configIndex !== -1)
|
|
31
|
+
return chartSlotColor(configIndex);
|
|
32
|
+
const declared = data.findIndex((row) => String(row[categoryKey]) === category);
|
|
33
|
+
return chartSlotColor(declared === -1 ? CHART_SLOT_COUNT - 1 : declared);
|
|
34
|
+
}, [config, data, categoryKey]);
|
|
35
|
+
const fixed = width !== undefined;
|
|
36
|
+
const resolvedHeight = height ?? (horizontal ? Math.max(160, rows.length * 36 + 24) : 240);
|
|
37
|
+
const formatCategory = categoryFormatter ?? ((v) => String(v ?? ""));
|
|
38
|
+
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 &&
|
|
39
|
+
rows.map((row, i) => {
|
|
40
|
+
const category = String(row[categoryKey] ?? i);
|
|
41
|
+
return _jsx(RechartsPrimitive.Cell, { fill: categoryColor(category) }, category);
|
|
42
|
+
}) })] }) }));
|
|
43
|
+
}
|
|
@@ -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,75 @@
|
|
|
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
|
+
/** Resolved label for a series: explicit config first, the key itself second. */
|
|
41
|
+
export declare function seriesLabel(config: ChartConfig, key: string): React.ReactNode;
|
|
42
|
+
/**
|
|
43
|
+
* Series keys are data-column names, which allow characters a CSS custom
|
|
44
|
+
* ident does not (`metrics.tokens`, `model name`). Everything that writes
|
|
45
|
+
* or reads a per-series variable goes through this one sanitizer, so the
|
|
46
|
+
* two sides can never disagree.
|
|
47
|
+
*/
|
|
48
|
+
export declare function seriesVarName(key: string): string;
|
|
49
|
+
/** The `var(--color-<key>)` reference marks use for their series color. */
|
|
50
|
+
export declare function seriesVar(key: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* The per-series CSS custom properties a `ChartContainer` injects, so chart
|
|
53
|
+
* marks can reference {@link seriesVar} and pick up theme changes live.
|
|
54
|
+
*/
|
|
55
|
+
export declare function chartStyleVars(config: ChartConfig): Record<string, string>;
|
|
56
|
+
/** 1029562 → "1M", 63093480 → "63.1M". For axes and stat tiles. */
|
|
57
|
+
export declare function formatCompactNumber(value: number): string;
|
|
58
|
+
/**
|
|
59
|
+
* Axis label for a time bucket as the usage/statistics APIs emit them:
|
|
60
|
+
* `"2026-08-26"` → localized "Aug 26", an ISO datetime (hour grain) →
|
|
61
|
+
* localized "14:00". Anything else passes through unchanged, so categorical
|
|
62
|
+
* x-axes can share the same formatter.
|
|
63
|
+
*/
|
|
64
|
+
export declare function formatBucketLabel(bucket: unknown): string;
|
|
65
|
+
/**
|
|
66
|
+
* Fold a sorted category list's tail into a single "Other" row once it
|
|
67
|
+
* exceeds `max` rows — the discipline for categorical charts: a 9th slice
|
|
68
|
+
* is never a 9th color.
|
|
69
|
+
*/
|
|
70
|
+
export declare function foldCategoryTail<T extends Record<string, unknown>>(rows: T[], options: {
|
|
71
|
+
categoryKey: keyof T & string;
|
|
72
|
+
valueKey: keyof T & string;
|
|
73
|
+
max?: number;
|
|
74
|
+
otherLabel?: string;
|
|
75
|
+
}): T[];
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
/** Resolved label for a series: explicit config first, the key itself second. */
|
|
22
|
+
export function seriesLabel(config, key) {
|
|
23
|
+
return config[key]?.label ?? key;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Series keys are data-column names, which allow characters a CSS custom
|
|
27
|
+
* ident does not (`metrics.tokens`, `model name`). Everything that writes
|
|
28
|
+
* or reads a per-series variable goes through this one sanitizer, so the
|
|
29
|
+
* two sides can never disagree.
|
|
30
|
+
*/
|
|
31
|
+
export function seriesVarName(key) {
|
|
32
|
+
const safe = key.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
33
|
+
if (safe === key)
|
|
34
|
+
return `--color-${key}`;
|
|
35
|
+
// Two keys may sanitize to the same ident ("metrics.tokens" vs
|
|
36
|
+
// "metrics tokens") — a short stable hash of the RAW key keeps them
|
|
37
|
+
// apart without leaking invalid characters.
|
|
38
|
+
let hash = 5381;
|
|
39
|
+
for (let i = 0; i < key.length; i++)
|
|
40
|
+
hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0;
|
|
41
|
+
return `--color-${safe}-${(hash >>> 0).toString(36)}`;
|
|
42
|
+
}
|
|
43
|
+
/** The `var(--color-<key>)` reference marks use for their series color. */
|
|
44
|
+
export function seriesVar(key) {
|
|
45
|
+
return `var(${seriesVarName(key)})`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The per-series CSS custom properties a `ChartContainer` injects, so chart
|
|
49
|
+
* marks can reference {@link seriesVar} and pick up theme changes live.
|
|
50
|
+
*/
|
|
51
|
+
export function chartStyleVars(config) {
|
|
52
|
+
const vars = {};
|
|
53
|
+
for (const key of Object.keys(config)) {
|
|
54
|
+
vars[seriesVarName(key)] = seriesColor(config, key);
|
|
55
|
+
}
|
|
56
|
+
return vars;
|
|
57
|
+
}
|
|
58
|
+
const compactFormatter = new Intl.NumberFormat(undefined, {
|
|
59
|
+
notation: "compact",
|
|
60
|
+
maximumFractionDigits: 1,
|
|
61
|
+
});
|
|
62
|
+
/** 1029562 → "1M", 63093480 → "63.1M". For axes and stat tiles. */
|
|
63
|
+
export function formatCompactNumber(value) {
|
|
64
|
+
if (!Number.isFinite(value))
|
|
65
|
+
return "–";
|
|
66
|
+
return compactFormatter.format(value);
|
|
67
|
+
}
|
|
68
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
69
|
+
/**
|
|
70
|
+
* Axis label for a time bucket as the usage/statistics APIs emit them:
|
|
71
|
+
* `"2026-08-26"` → localized "Aug 26", an ISO datetime (hour grain) →
|
|
72
|
+
* localized "14:00". Anything else passes through unchanged, so categorical
|
|
73
|
+
* x-axes can share the same formatter.
|
|
74
|
+
*/
|
|
75
|
+
export function formatBucketLabel(bucket) {
|
|
76
|
+
if (typeof bucket !== "string")
|
|
77
|
+
return String(bucket ?? "");
|
|
78
|
+
if (DATE_ONLY.test(bucket)) {
|
|
79
|
+
const d = new Date(`${bucket}T00:00:00`);
|
|
80
|
+
if (!Number.isNaN(d.getTime())) {
|
|
81
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const asDate = new Date(bucket);
|
|
85
|
+
if (bucket.includes("T") && !Number.isNaN(asDate.getTime())) {
|
|
86
|
+
return asDate.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
87
|
+
}
|
|
88
|
+
return bucket;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fold a sorted category list's tail into a single "Other" row once it
|
|
92
|
+
* exceeds `max` rows — the discipline for categorical charts: a 9th slice
|
|
93
|
+
* is never a 9th color.
|
|
94
|
+
*/
|
|
95
|
+
export function foldCategoryTail(rows, options) {
|
|
96
|
+
const { categoryKey, valueKey, otherLabel = "Other" } = options;
|
|
97
|
+
// A cap below 2 cannot hold a fold (one real row + "Other" is the floor).
|
|
98
|
+
const max = Math.max(2, options.max ?? CHART_SLOT_COUNT);
|
|
99
|
+
if (rows.length <= max)
|
|
100
|
+
return rows;
|
|
101
|
+
const head = rows.slice(0, max - 1);
|
|
102
|
+
const tail = rows.slice(max - 1);
|
|
103
|
+
// A pre-existing "Other" row in the head merges into the fold — two
|
|
104
|
+
// rows wearing the same label would double the category.
|
|
105
|
+
const kept = head.filter((row) => String(row[categoryKey]) !== otherLabel);
|
|
106
|
+
const folded = [...head.filter((row) => String(row[categoryKey]) === otherLabel), ...tail];
|
|
107
|
+
const otherTotal = folded.reduce((sum, row) => sum + (Number(row[valueKey]) || 0), 0);
|
|
108
|
+
const other = {
|
|
109
|
+
...(tail[0] ?? folded[0]),
|
|
110
|
+
[categoryKey]: otherLabel,
|
|
111
|
+
[valueKey]: otherTotal,
|
|
112
|
+
};
|
|
113
|
+
return [...kept, other];
|
|
114
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
* `--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 `var(--color-<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
|
+
/**
|
|
52
|
+
* Override the indicator color per row — for charts whose CELLS recolor
|
|
53
|
+
* the marks (colorByCategory) while the payload still carries the parent
|
|
54
|
+
* series color.
|
|
55
|
+
*/
|
|
56
|
+
indicatorColor?: (entryKey: string, label: unknown) => string | undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The hover layer's content. Rows are named from the chart config (the
|
|
60
|
+
* series key is a data column name — never show it raw when a label exists).
|
|
61
|
+
*/
|
|
62
|
+
declare function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator, indicatorColor, }: ChartTooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
63
|
+
export interface ChartLegendContentProps {
|
|
64
|
+
payload?: Array<{
|
|
65
|
+
dataKey?: string | number;
|
|
66
|
+
value?: unknown;
|
|
67
|
+
color?: string;
|
|
68
|
+
}>;
|
|
69
|
+
className?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Legend rows named from the chart config, color chips from the payload. */
|
|
72
|
+
declare function ChartLegendContent({ payload, className }: ChartLegendContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
73
|
+
/** Compact y-axis tick formatter shared by the chart wrappers. */
|
|
74
|
+
export declare function compactAxisTick(value: unknown): string;
|
|
75
|
+
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, 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: [_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 ?? "") })] }, 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) })] }, 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,29 @@
|
|
|
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 its theme slot in data order — and there are only five slots, so
|
|
6
|
+
* `maxSlices` (default 5) folds the tail into a muted "Other" instead of
|
|
7
|
+
* inventing colors. The center carries the headline total; the legend
|
|
8
|
+
* carries names + shares, so the hover layer is confirmation, not the only
|
|
9
|
+
* way to read the chart.
|
|
10
|
+
*/
|
|
11
|
+
export interface DonutChartProps {
|
|
12
|
+
data: Array<Record<string, unknown>>;
|
|
13
|
+
categoryKey: string;
|
|
14
|
+
valueKey: string;
|
|
15
|
+
/** Per-category label/color overrides, keyed by category value. */
|
|
16
|
+
config?: ChartConfig;
|
|
17
|
+
/** Fold categories beyond this count into "Other". Default 5. */
|
|
18
|
+
maxSlices?: number;
|
|
19
|
+
centerLabel?: React.ReactNode;
|
|
20
|
+
/** Defaults to the compact-formatted sum of all slices. */
|
|
21
|
+
centerValue?: React.ReactNode;
|
|
22
|
+
height?: number;
|
|
23
|
+
/** Fixed pixel width opts out of responsive measurement (tests, embeds). */
|
|
24
|
+
width?: number;
|
|
25
|
+
valueFormatter?: (value: number) => React.ReactNode;
|
|
26
|
+
showLegend?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
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,37 @@
|
|
|
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, chartSlotColor, foldCategoryTail, formatCompactNumber, } from "./chart-config.js";
|
|
7
|
+
const OTHER_COLOR = "var(--muted-foreground)";
|
|
8
|
+
export function DonutChart({ data, categoryKey, valueKey, 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
|
+
const rows = React.useMemo(() => foldCategoryTail(data, { categoryKey, valueKey, max: cappedSlices }), [data, categoryKey, valueKey, cappedSlices]);
|
|
13
|
+
const total = React.useMemo(() => rows.reduce((sum, row) => sum + (Number(row[valueKey]) || 0), 0), [rows, valueKey]);
|
|
14
|
+
const sliceColor = React.useCallback((category, index) => {
|
|
15
|
+
if (config[category]?.color)
|
|
16
|
+
return config[category].color;
|
|
17
|
+
if (category === "Other" && index === rows.length - 1 && data.length > rows.length)
|
|
18
|
+
return OTHER_COLOR;
|
|
19
|
+
// Declared identity first (immune to data filtering/sorting); the
|
|
20
|
+
// row position is only the fallback for undeclared categories.
|
|
21
|
+
const configIndex = Object.keys(config).indexOf(category);
|
|
22
|
+
return chartSlotColor(configIndex !== -1 ? configIndex : index);
|
|
23
|
+
}, [config, rows.length, data.length]);
|
|
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, { labelFormatter: () => null, 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 value = Number(row[valueKey]) || 0;
|
|
32
|
+
const pct = total > 0 ? (value / total) * 100 : 0;
|
|
33
|
+
// A real slice must never read as absent: below 1% says so.
|
|
34
|
+
const share = pct > 0 && pct < 1 ? "<1" : String(Math.round(pct));
|
|
35
|
+
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, i) } }), _jsx("span", { children: sliceLabel(category) }), _jsxs("span", { className: "tabular-nums", children: [share, "%"] })] }, category));
|
|
36
|
+
}) }))] }));
|
|
37
|
+
}
|
|
@@ -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,22 @@
|
|
|
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
|
+
return format === "plain" ? value.toLocaleString() : formatCompactNumber(value);
|
|
11
|
+
}
|
|
12
|
+
const StatCard = React.forwardRef(({ label, value, format = "compact", delta, deltaLabel, invertDelta = false, hint, icon, loading = false, className, ...props }, ref) => {
|
|
13
|
+
const direction = delta === undefined || delta === 0 ? "flat" : delta > 0 ? "up" : "down";
|
|
14
|
+
const good = delta !== undefined && delta !== 0 && (invertDelta ? delta < 0 : delta > 0);
|
|
15
|
+
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 && delta !== undefined && (_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"
|
|
16
|
+
? "bg-muted text-muted-foreground"
|
|
17
|
+
: good
|
|
18
|
+
? "bg-success/10 text-success"
|
|
19
|
+
: "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] }))] }));
|
|
20
|
+
});
|
|
21
|
+
StatCard.displayName = "StatCard";
|
|
22
|
+
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,18 @@
|
|
|
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 { 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
|
+
const legend = showLegend ?? keys.length > 1;
|
|
8
|
+
const fixed = width !== undefined;
|
|
9
|
+
const ChartRoot = kind === "line"
|
|
10
|
+
? RechartsPrimitive.LineChart
|
|
11
|
+
: kind === "bar"
|
|
12
|
+
? RechartsPrimitive.BarChart
|
|
13
|
+
: RechartsPrimitive.AreaChart;
|
|
14
|
+
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), 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), 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), fill: seriesVar(key), fillOpacity: 0.25, strokeWidth: 2,
|
|
15
|
+
// A one-or-two point window would otherwise draw a floating
|
|
16
|
+
// speck; dots make sparse data read as sparse, not broken.
|
|
17
|
+
dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)))] }) }));
|
|
18
|
+
}
|
|
@@ -211,7 +211,7 @@ function statusIconClass(tone) {
|
|
|
211
211
|
return "text-primary";
|
|
212
212
|
case "neutral":
|
|
213
213
|
default:
|
|
214
|
-
return "text-sidebar-
|
|
214
|
+
return "text-sidebar-icon";
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
function NavStatusIcon({ statusIcon }) {
|
|
@@ -481,7 +481,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
|
|
|
481
481
|
? "text-sidebar-foreground/45 cursor-default"
|
|
482
482
|
: isActive
|
|
483
483
|
? ACTIVE_NAV_TEXT_CLASS
|
|
484
|
-
: "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-
|
|
484
|
+
: "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
|
|
485
485
|
? ACTIVE_NAV_BADGE_CLASS
|
|
486
486
|
: "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
|
|
487
487
|
}
|
|
@@ -492,7 +492,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
|
|
|
492
492
|
!isDisabled &&
|
|
493
493
|
"ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
|
|
494
494
|
!isDisabled &&
|
|
495
|
-
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-
|
|
495
|
+
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _jsx("span", { className: cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS), children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] }));
|
|
496
496
|
}
|
|
497
497
|
function SubNavItems({ items, parentPath, depth = 1, }) {
|
|
498
498
|
const navigate = useNavigate();
|
|
@@ -586,7 +586,7 @@ function NestedFolderItem({ item, depth }) {
|
|
|
586
586
|
? ACTIVE_NAV_ICON_CLASS
|
|
587
587
|
: isAncestorActive
|
|
588
588
|
? "text-sidebar-foreground/80"
|
|
589
|
-
: "text-sidebar-
|
|
589
|
+
: "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
|
|
590
590
|
? ACTIVE_NAV_BADGE_CLASS
|
|
591
591
|
: "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }), isExpanded && item.children && (_jsxs("div", { children: [item.children.map((child) => {
|
|
592
592
|
const hasNestedChildren = child.children && child.children.length > 0;
|
|
@@ -660,7 +660,7 @@ function CollapsibleNavItem({ item, inTree = true }) {
|
|
|
660
660
|
? ACTIVE_NAV_ICON_CLASS
|
|
661
661
|
: ancestor
|
|
662
662
|
? "text-sidebar-foreground/80"
|
|
663
|
-
: "text-sidebar-
|
|
663
|
+
: "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", selected
|
|
664
664
|
? ACTIVE_NAV_BADGE_CLASS
|
|
665
665
|
: "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
|
|
666
666
|
return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
|
|
@@ -679,7 +679,7 @@ function CollapsibleNavItem({ item, inTree = true }) {
|
|
|
679
679
|
"rounded-xl border border-transparent pr-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
|
|
680
680
|
!isFileDragOver &&
|
|
681
681
|
"bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver &&
|
|
682
|
-
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-
|
|
682
|
+
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
|
|
683
683
|
? ACTIVE_NAV_BADGE_CLASS
|
|
684
684
|
: "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] })) }));
|
|
685
685
|
}
|
|
@@ -743,7 +743,7 @@ function SidebarResizeHandle() {
|
|
|
743
743
|
// group is now the source of truth for "you're in a chat right now".
|
|
744
744
|
// Kept as a comment so future spelunkers know it was intentional.
|
|
745
745
|
function CollapsedNavShortcut({ to, label, icon: Icon, isActive, }) {
|
|
746
|
-
return (_jsx(NavLink, { to: to, children: _jsx(TooltipIconButton, { tooltip: label, side: "right", className: cn(isActive && "border border-transparent bg-sidebar-selected text-sidebar-foreground"), children: _jsx(Icon, { className: cn("size-4", isActive
|
|
746
|
+
return (_jsx(NavLink, { to: to, children: _jsx(TooltipIconButton, { tooltip: label, side: "right", className: cn(isActive && "border border-transparent bg-sidebar-selected text-sidebar-foreground"), children: _jsx(Icon, { className: cn("size-4", isActive ? "text-primary" : "text-sidebar-icon") }) }) }, to));
|
|
747
747
|
}
|
|
748
748
|
// ---------------------------------------------------------------------------
|
|
749
749
|
// Collapsible nav-group state — persisted per-label in localStorage so the
|
|
@@ -946,7 +946,7 @@ function SidebarContent({ collapsed }) {
|
|
|
946
946
|
e.preventDefault();
|
|
947
947
|
}, []);
|
|
948
948
|
if (collapsed) {
|
|
949
|
-
return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-3 flex-1", children: [_jsx(TooltipIconButton, { tooltip: "Expand sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeftOpen, { className: "size-5" }) }), _jsx(TooltipIconButton, { tooltip: "New Thread", side: "right", onClick: handleNewConversation, children: _jsx(SquarePen, { className: "size-4" }) }), _jsx("div", { className: "mt-4 flex w-full flex-col items-stretch gap-1", children: navConfig
|
|
949
|
+
return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-3 flex-1", children: [_jsx(TooltipIconButton, { tooltip: "Expand sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeftOpen, { className: "size-5" }) }), _jsx(TooltipIconButton, { tooltip: "New Thread", side: "right", onClick: handleNewConversation, children: _jsx(SquarePen, { className: "size-4 text-sidebar-icon" }) }), _jsx("div", { className: "mt-4 flex w-full flex-col items-stretch gap-1", children: navConfig
|
|
950
950
|
.map((group, groupIndex) => {
|
|
951
951
|
// Modules with a non-flat collapsed rail (e.g. Spaces'
|
|
952
952
|
// "header + active workspace") supply `collapsedRail`; the
|
|
@@ -980,7 +980,7 @@ function SidebarContent({ collapsed }) {
|
|
|
980
980
|
// column so icons stay aligned with the rail.
|
|
981
981
|
_jsx("div", { className: cn("flex flex-col items-center gap-1", visibleIndex > 0 && "mt-2 pt-2 border-t border-sidebar-border/40"), children: items.map(({ to, label, icon, isActive }) => (_jsx(CollapsedNavShortcut, { to: to, label: label, icon: icon, isActive: isActive }, to))) }, `${group.label || ""}-${groupIndex}`))) })] }));
|
|
982
982
|
}
|
|
983
|
-
return (_jsxs("div", { className: "flex flex-col flex-1 min-h-0", children: [_jsxs("div", { className: "flex items-center justify-between px-3 py-3", children: [_jsx(NavLink, { to: "/", className: "flex items-center gap-2 hover:opacity-80 transition-opacity", children: _jsx(AppBrand, { iconClassName: "text-primary", labelClassName: "truncate text-sm font-semibold text-sidebar-foreground" }) }), _jsx(TooltipIconButton, { tooltip: "Collapse sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeft, { className: "size-5" }) })] }), _jsx("div", { className: "px-1 mb-0.5", children: _jsxs("button", { type: "button", onClick: handleNewConversation, className: cn("flex items-center gap-2 w-full", "rounded-xl px-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70"), children: [_jsx(SquarePen, { className: "size-4 shrink-0 text-sidebar-
|
|
983
|
+
return (_jsxs("div", { className: "flex flex-col flex-1 min-h-0", children: [_jsxs("div", { className: "flex items-center justify-between px-3 py-3", children: [_jsx(NavLink, { to: "/", className: "flex items-center gap-2 hover:opacity-80 transition-opacity", children: _jsx(AppBrand, { iconClassName: "text-primary", labelClassName: "truncate text-sm font-semibold text-sidebar-foreground" }) }), _jsx(TooltipIconButton, { tooltip: "Collapse sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeft, { className: "size-5" }) })] }), _jsx("div", { className: "px-1 mb-0.5", children: _jsxs("button", { type: "button", onClick: handleNewConversation, className: cn("flex items-center gap-2 w-full", "rounded-xl px-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70"), children: [_jsx(SquarePen, { className: "size-4 shrink-0 text-sidebar-icon" }), "New Thread"] }) }), _jsxs("nav", { className: "flex-1 overflow-y-auto px-1 pb-16", onDragOverCapture: preventNativeFileDrop, onDropCapture: preventNativeFileDrop, children: [navConfig.map((group) => (_jsx(NavGroupSection, { group: group }, group.label))), showNavSkeleton && (_jsx("div", { className: "space-y-1 px-1 pt-2", "aria-hidden": "true", children: Array.from({ length: 6 }).map((_, index) => (_jsx("div", { className: "h-7 mx-1 rounded-lg animate-pulse bg-sidebar-accent/40" }, index))) }))] }), _jsx(SidebarFooterActions, {})] }));
|
|
984
984
|
}
|
|
985
985
|
function SidebarFooterActions() {
|
|
986
986
|
const footerActions = useNavStore((s) => s.footerActions);
|
|
@@ -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
|
-
|
|
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
|
|
@@ -30,6 +30,16 @@ export interface ThemeSidebarSlots {
|
|
|
30
30
|
sidebarAccentForeground?: string;
|
|
31
31
|
sidebarSelected?: string;
|
|
32
32
|
sidebarSelectedForeground?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Colour of the icons in the sidebar's idle rows.
|
|
35
|
+
*
|
|
36
|
+
* Separate from `sidebarForeground` because a brand whose mark is a colour
|
|
37
|
+
* usually wants the icons in that colour and the LABELS still readable —
|
|
38
|
+
* tinting `sidebarForeground` to reach the icons drags every label with it.
|
|
39
|
+
* Unset resolves to the previous behaviour (the label colour at 60%), so no
|
|
40
|
+
* existing theme moves.
|
|
41
|
+
*/
|
|
42
|
+
sidebarIcon?: string;
|
|
33
43
|
sidebarBorder?: string;
|
|
34
44
|
sidebarRing?: string;
|
|
35
45
|
}
|
|
@@ -99,6 +109,21 @@ export interface ThemeBranding {
|
|
|
99
109
|
*/
|
|
100
110
|
iconUrl?: string;
|
|
101
111
|
darkIconUrl?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Rendered height of the mark, as a CSS length (`"2rem"`, `"32px"`).
|
|
114
|
+
* Defaults to the 20px lockup height when unset.
|
|
115
|
+
*
|
|
116
|
+
* A wordmark is not interchangeable with an icon at one fixed height. Marks
|
|
117
|
+
* that carry a tagline under the name — a common wordmark shape — turn the
|
|
118
|
+
* tagline into an unreadable smear at 20px, and the whole lockup reads as
|
|
119
|
+
* undersized next to the product name. The tenant supplying the asset is the
|
|
120
|
+
* only one who knows how much height it needs, so this is theirs to set.
|
|
121
|
+
*
|
|
122
|
+
* Validated, not interpolated blindly: the value reaches an inline style and
|
|
123
|
+
* themes are tenant-authored content, so anything that is not a plain
|
|
124
|
+
* non-negative CSS length in px/rem/em is ignored in favour of the default.
|
|
125
|
+
*/
|
|
126
|
+
logoHeight?: string;
|
|
102
127
|
}
|
|
103
128
|
export interface ThemeDefinition {
|
|
104
129
|
light?: ThemeColorSlots;
|
|
@@ -28,6 +28,7 @@ const SIDEBAR_KEYS = [
|
|
|
28
28
|
"sidebarAccentForeground",
|
|
29
29
|
"sidebarSelected",
|
|
30
30
|
"sidebarSelectedForeground",
|
|
31
|
+
"sidebarIcon",
|
|
31
32
|
"sidebarBorder",
|
|
32
33
|
"sidebarRing",
|
|
33
34
|
];
|
|
@@ -84,6 +85,7 @@ const BRANDING_KEYS = [
|
|
|
84
85
|
"darkLogoUrl",
|
|
85
86
|
"iconUrl",
|
|
86
87
|
"darkIconUrl",
|
|
88
|
+
"logoHeight",
|
|
87
89
|
];
|
|
88
90
|
function cleanObject(value, keys) {
|
|
89
91
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
@@ -188,6 +190,7 @@ function toCssVars(colors, sidebar, status, charts, typography, radius, app) {
|
|
|
188
190
|
["--sidebar-accent-foreground"]: sidebar.sidebarAccentForeground,
|
|
189
191
|
["--sidebar-selected"]: sidebar.sidebarSelected,
|
|
190
192
|
["--sidebar-selected-foreground"]: sidebar.sidebarSelectedForeground,
|
|
193
|
+
["--sidebar-icon"]: sidebar.sidebarIcon,
|
|
191
194
|
["--sidebar-border"]: sidebar.sidebarBorder,
|
|
192
195
|
["--sidebar-ring"]: sidebar.sidebarRing,
|
|
193
196
|
["--success"]: status.success,
|
|
@@ -248,6 +251,7 @@ function defaultSidebar(colors) {
|
|
|
248
251
|
sidebarAccentForeground: colors.accentForeground,
|
|
249
252
|
sidebarSelected: colors.muted,
|
|
250
253
|
sidebarSelectedForeground: colors.foreground,
|
|
254
|
+
sidebarIcon: `color-mix(in srgb, ${colors.foreground} 60%, transparent)`,
|
|
251
255
|
sidebarBorder: colors.border,
|
|
252
256
|
sidebarRing: colors.ring,
|
|
253
257
|
};
|
|
@@ -376,6 +380,7 @@ export const FOUNDRY_VIOLET_THEME = {
|
|
|
376
380
|
sidebarAccentForeground: "color-mix(in srgb, var(--foreground) 92%, var(--primary) 8%)",
|
|
377
381
|
sidebarSelected: "var(--muted)",
|
|
378
382
|
sidebarSelectedForeground: "var(--foreground)",
|
|
383
|
+
sidebarIcon: "color-mix(in srgb, var(--sidebar-foreground) 60%, transparent)",
|
|
379
384
|
sidebarBorder: "color-mix(in srgb, var(--border) 96%, var(--foreground) 4%)",
|
|
380
385
|
sidebarRing: "oklch(43% 0.24 286)",
|
|
381
386
|
},
|
|
@@ -388,6 +393,7 @@ export const FOUNDRY_VIOLET_THEME = {
|
|
|
388
393
|
sidebarAccentForeground: "color-mix(in srgb, var(--foreground) 90%, white 10%)",
|
|
389
394
|
sidebarSelected: "color-mix(in srgb, var(--muted) 78%, var(--background) 22%)",
|
|
390
395
|
sidebarSelectedForeground: "var(--foreground)",
|
|
396
|
+
sidebarIcon: "color-mix(in srgb, var(--sidebar-foreground) 60%, transparent)",
|
|
391
397
|
sidebarBorder: "color-mix(in srgb, var(--border) 90%, white 10%)",
|
|
392
398
|
sidebarRing: "oklch(0.72 0.18 286)",
|
|
393
399
|
},
|
package/dist/styles.css
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
34
34
|
--color-sidebar-selected: var(--sidebar-selected);
|
|
35
35
|
--color-sidebar-selected-foreground: var(--sidebar-selected-foreground);
|
|
36
|
+
--color-sidebar-icon: var(--sidebar-icon);
|
|
36
37
|
--color-sidebar-border: var(--sidebar-border);
|
|
37
38
|
--color-sidebar-ring: var(--sidebar-ring);
|
|
38
39
|
--color-success: var(--success);
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
78
79
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
79
80
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
81
|
+
--sidebar-icon: color-mix(in srgb, var(--sidebar-foreground) 60%, transparent);
|
|
80
82
|
--sidebar-selected: var(--muted);
|
|
81
83
|
--sidebar-selected-foreground: var(--foreground);
|
|
82
84
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
@@ -122,6 +124,7 @@
|
|
|
122
124
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
123
125
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
124
126
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
127
|
+
--sidebar-icon: color-mix(in srgb, var(--sidebar-foreground) 60%, transparent);
|
|
125
128
|
--sidebar-selected: color-mix(in srgb, var(--muted) 78%, var(--background) 22%);
|
|
126
129
|
--sidebar-selected-foreground: var(--foreground);
|
|
127
130
|
--sidebar-border: oklch(1 0 0 / 10%);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iloveagents/foundry-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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
|
-
"
|
|
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.
|
|
75
|
-
"@iloveagents/foundry-web-primitives": "^0.
|
|
79
|
+
"@iloveagents/foundry-agent": "^0.23.0",
|
|
80
|
+
"@iloveagents/foundry-web-primitives": "^0.23.0"
|
|
76
81
|
},
|
|
77
82
|
"devDependencies": {
|
|
78
83
|
"@ag-ui/client": "^0.0.52",
|