@alpic-ai/ui 0.0.0-dev.g05467b7 → 0.0.0-dev.g05c89ce

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/components/area-chart.d.mts +2 -0
  2. package/dist/components/area-chart.mjs +9 -3
  3. package/dist/components/bar-chart.d.mts +2 -0
  4. package/dist/components/bar-chart.mjs +9 -3
  5. package/dist/components/bar-list.d.mts +3 -0
  6. package/dist/components/bar-list.mjs +25 -12
  7. package/dist/components/chart-card.d.mts +1 -1
  8. package/dist/components/chart-card.mjs +1 -1
  9. package/dist/components/chart-container.d.mts +1 -1
  10. package/dist/components/chart-legend.d.mts +5 -0
  11. package/dist/components/chart-legend.mjs +11 -2
  12. package/dist/components/donut-chart.mjs +9 -5
  13. package/dist/components/form.mjs +1 -1
  14. package/dist/components/heatmap-chart.d.mts +8 -0
  15. package/dist/components/heatmap-chart.mjs +39 -8
  16. package/dist/components/line-chart.d.mts +2 -0
  17. package/dist/components/line-chart.mjs +10 -3
  18. package/dist/components/stat.d.mts +3 -1
  19. package/dist/components/stat.mjs +14 -4
  20. package/dist/components/wizard.d.mts +1 -19
  21. package/dist/components/wizard.mjs +1 -19
  22. package/dist/lib/chart.mjs +16 -1
  23. package/package.json +23 -23
  24. package/src/components/area-chart.tsx +12 -4
  25. package/src/components/bar-chart.tsx +12 -4
  26. package/src/components/bar-list.tsx +26 -10
  27. package/src/components/chart-card.tsx +8 -6
  28. package/src/components/chart-container.tsx +2 -0
  29. package/src/components/chart-legend.tsx +10 -2
  30. package/src/components/donut-chart.tsx +6 -6
  31. package/src/components/form.tsx +1 -1
  32. package/src/components/heatmap-chart.tsx +62 -18
  33. package/src/components/line-chart.tsx +18 -5
  34. package/src/components/stat.tsx +10 -6
  35. package/src/components/wizard.tsx +1 -35
  36. package/src/lib/chart.ts +34 -0
  37. package/src/stories/area-chart.stories.tsx +1 -3
  38. package/src/stories/bar-chart.stories.tsx +1 -3
  39. package/src/stories/bar-list.stories.tsx +1 -3
  40. package/src/stories/donut-chart.stories.tsx +1 -3
  41. package/src/stories/heatmap-chart.stories.tsx +1 -3
  42. package/src/stories/line-chart.stories.tsx +1 -3
  43. package/src/stories/wizard.stories.tsx +23 -5
  44. package/src/styles/tokens.css +0 -45
  45. package/dist/components/grid-fx.d.mts +0 -13
  46. package/dist/components/grid-fx.mjs +0 -188
  47. package/src/components/grid-fx.tsx +0 -238
@@ -1,25 +1,7 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.mjs";
3
- import { TabsNav, TabsNavList, TabsNavTrigger } from "./tabs.mjs";
4
3
  import { jsx, jsxs } from "react/jsx-runtime";
5
4
  //#region src/components/wizard.tsx
6
- function WizardSteps({ steps, activeIdx, onSelect, ariaLabel = "Wizard steps", className }) {
7
- return /* @__PURE__ */ jsx(TabsNav, {
8
- orientation: "vertical",
9
- "aria-label": ariaLabel,
10
- className,
11
- children: /* @__PURE__ */ jsx(TabsNavList, { children: steps.map((step, idx) => /* @__PURE__ */ jsx(TabsNavTrigger, {
12
- active: idx === activeIdx,
13
- asChild: true,
14
- children: /* @__PURE__ */ jsx("button", {
15
- type: "button",
16
- onClick: () => onSelect(idx),
17
- className: "w-full justify-start text-left",
18
- children: step.label
19
- })
20
- }, step.id)) })
21
- });
22
- }
23
5
  function WizardProgress({ current, total, className, ...props }) {
24
6
  const percent = total > 0 ? Math.round(current / total * 100) : 0;
25
7
  return /* @__PURE__ */ jsxs("div", {
@@ -43,4 +25,4 @@ function WizardProgress({ current, total, className, ...props }) {
43
25
  });
44
26
  }
45
27
  //#endregion
46
- export { WizardProgress, WizardSteps };
28
+ export { WizardProgress };
@@ -1,4 +1,5 @@
1
1
  import { luminance, paletteColor } from "./chart-palette.mjs";
2
+ import * as React$1 from "react";
2
3
  //#region src/lib/chart.ts
3
4
  const formatShare = (fraction) => `${(fraction * 100).toFixed(fraction >= .1 ? 0 : 1)}%`;
4
5
  const semanticColor = (theme, semantic) => {
@@ -23,5 +24,19 @@ const resolveSeries = (series, palette, theme) => series.map((entry, index) => (
23
24
  * stack order in Recharts — first entry sits at the bottom.
24
25
  */
25
26
  const orderByLuminance = (series) => [...series].sort((lower, upper) => luminance(lower.color) - luminance(upper.color));
27
+ const makeXAxisTick = (theme) => ({ x, y, payload, index, visibleTicksCount }) => {
28
+ const isFirst = index === 0;
29
+ const isLast = visibleTicksCount != null && index === visibleTicksCount - 1;
30
+ const anchor = isFirst ? "start" : isLast ? "end" : "middle";
31
+ return React$1.createElement("text", {
32
+ x: Number(x ?? 0),
33
+ y: Number(y ?? 0),
34
+ dy: 12,
35
+ textAnchor: anchor,
36
+ fill: theme.axisForeground,
37
+ fontFamily: theme.fontMono,
38
+ fontSize: 10
39
+ }, String(payload?.value ?? ""));
40
+ };
26
41
  //#endregion
27
- export { formatShare, orderByLuminance, resolveSeries };
42
+ export { formatShare, makeXAxisTick, orderByLuminance, resolveSeries };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alpic-ai/ui",
3
- "version": "0.0.0-dev.g05467b7",
3
+ "version": "0.0.0-dev.g05c89ce",
4
4
  "description": "Alpic design system — shared UI components",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,32 +23,32 @@
23
23
  "src"
24
24
  ],
25
25
  "peerDependencies": {
26
- "lucide-react": "^1.18.0",
26
+ "lucide-react": "^1.21.0",
27
27
  "react": "^19.2.7",
28
28
  "react-dom": "^19.2.7",
29
- "react-hook-form": "^7.79.0",
29
+ "react-hook-form": "^7.80.0",
30
30
  "sonner": "^2.0.7",
31
31
  "tailwindcss": "^4.3.1",
32
32
  "tw-animate-css": "^1.4.0"
33
33
  },
34
34
  "dependencies": {
35
- "@radix-ui/react-accordion": "^1.2.13",
36
- "@radix-ui/react-avatar": "^1.1.12",
37
- "@radix-ui/react-checkbox": "^1.3.4",
38
- "@radix-ui/react-collapsible": "^1.1.13",
39
- "@radix-ui/react-dialog": "^1.1.16",
40
- "@radix-ui/react-dropdown-menu": "^2.1.17",
41
- "@radix-ui/react-label": "^2.1.9",
42
- "@radix-ui/react-popover": "^1.1.16",
43
- "@radix-ui/react-radio-group": "^1.4.0",
44
- "@radix-ui/react-scroll-area": "^1.2.11",
45
- "@radix-ui/react-select": "^2.3.0",
46
- "@radix-ui/react-separator": "^1.1.9",
47
- "@radix-ui/react-slot": "^1.2.5",
48
- "@radix-ui/react-switch": "^1.3.0",
49
- "@radix-ui/react-tabs": "^1.1.14",
50
- "@radix-ui/react-toggle-group": "^1.1.12",
51
- "@radix-ui/react-tooltip": "^1.2.9",
35
+ "@radix-ui/react-accordion": "^1.2.14",
36
+ "@radix-ui/react-avatar": "^1.2.0",
37
+ "@radix-ui/react-checkbox": "^1.3.5",
38
+ "@radix-ui/react-collapsible": "^1.1.14",
39
+ "@radix-ui/react-dialog": "^1.1.17",
40
+ "@radix-ui/react-dropdown-menu": "^2.1.18",
41
+ "@radix-ui/react-label": "^2.1.10",
42
+ "@radix-ui/react-popover": "^1.1.17",
43
+ "@radix-ui/react-radio-group": "^1.4.1",
44
+ "@radix-ui/react-scroll-area": "^1.2.12",
45
+ "@radix-ui/react-select": "^2.3.1",
46
+ "@radix-ui/react-separator": "^1.1.10",
47
+ "@radix-ui/react-slot": "^1.3.0",
48
+ "@radix-ui/react-switch": "^1.3.1",
49
+ "@radix-ui/react-tabs": "^1.1.15",
50
+ "@radix-ui/react-toggle-group": "^1.1.13",
51
+ "@radix-ui/react-tooltip": "^1.2.10",
52
52
  "class-variance-authority": "^0.7.1",
53
53
  "clsx": "^2.1.1",
54
54
  "cmdk": "^1.1.1",
@@ -60,12 +60,12 @@
60
60
  "@tailwindcss/postcss": "^4.3.1",
61
61
  "@types/react": "19.2.17",
62
62
  "@types/react-dom": "19.2.3",
63
- "lucide-react": "^1.18.0",
64
- "react-hook-form": "^7.79.0",
63
+ "lucide-react": "^1.21.0",
64
+ "react-hook-form": "^7.80.0",
65
65
  "shx": "^0.4.0",
66
66
  "sonner": "^2.0.7",
67
67
  "tailwindcss": "^4.3.1",
68
- "tsdown": "^0.22.2",
68
+ "tsdown": "^0.22.3",
69
69
  "tw-animate-css": "^1.4.0",
70
70
  "typescript": "^6.0.3"
71
71
  },
@@ -16,7 +16,7 @@ import {
16
16
  } from "recharts";
17
17
 
18
18
  import { useReducedMotion } from "../hooks/use-reduced-motion";
19
- import { type ChartSeries, orderByLuminance, resolveSeries } from "../lib/chart";
19
+ import { type ChartSeries, makeXAxisTick, orderByLuminance, resolveSeries } from "../lib/chart";
20
20
  import type { ChartPaletteName } from "../lib/chart-palette";
21
21
  import { cn } from "../lib/cn";
22
22
  import { useChartContext } from "./chart-container";
@@ -39,6 +39,7 @@ export interface AreaChartProps {
39
39
  variant?: "stacked" | "grouped" | "expand";
40
40
  curve?: keyof typeof CURVE_TYPE;
41
41
  legend?: boolean;
42
+ legendAlign?: "left" | "center" | "right";
42
43
  valueFlags?: boolean;
43
44
  height?: number;
44
45
  yAxisWidth?: number;
@@ -60,6 +61,7 @@ function AreaChart({
60
61
  variant = "stacked",
61
62
  curve = "monotone",
62
63
  legend = false,
64
+ legendAlign = "left",
63
65
  valueFlags = false,
64
66
  height = 200,
65
67
  yAxisWidth = 48,
@@ -205,7 +207,7 @@ function AreaChart({
205
207
  no data in range
206
208
  </div>
207
209
  ) : (
208
- <ResponsiveContainer width="100%" height="100%">
210
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
209
211
  <RechartsAreaChart
210
212
  data={data as Record<string, string | number>[]}
211
213
  stackOffset={variant === "expand" ? "expand" : "none"}
@@ -234,7 +236,13 @@ function AreaChart({
234
236
  </defs>
235
237
 
236
238
  <CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
237
- <XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
239
+ <XAxis
240
+ dataKey={index}
241
+ {...axis}
242
+ tick={makeXAxisTick(theme)}
243
+ interval="preserveStartEnd"
244
+ minTickGap={44}
245
+ />
238
246
  <YAxis
239
247
  {...axis}
240
248
  width={yAxisWidth}
@@ -331,7 +339,7 @@ function AreaChart({
331
339
  )}
332
340
  </div>
333
341
 
334
- {legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
342
+ {legend && !isEmpty && <ChartLegend items={legendItems} align={legendAlign} insetLeft={yAxisWidth} />}
335
343
  </div>
336
344
  );
337
345
  }
@@ -16,7 +16,7 @@ import {
16
16
  } from "recharts";
17
17
 
18
18
  import { useReducedMotion } from "../hooks/use-reduced-motion";
19
- import { type ChartSeries, orderByLuminance, resolveSeries } from "../lib/chart";
19
+ import { type ChartSeries, makeXAxisTick, orderByLuminance, resolveSeries } from "../lib/chart";
20
20
  import type { ChartPaletteName } from "../lib/chart-palette";
21
21
  import { cn } from "../lib/cn";
22
22
  import type { ChartMarker } from "./area-chart";
@@ -33,6 +33,7 @@ export interface BarChartProps {
33
33
  series: ChartSeries[];
34
34
  variant?: "stacked" | "grouped" | "expand";
35
35
  legend?: boolean;
36
+ legendAlign?: "left" | "center" | "right";
36
37
  valueLabels?: boolean;
37
38
  height?: number;
38
39
  yAxisWidth?: number;
@@ -52,6 +53,7 @@ function BarChart({
52
53
  series,
53
54
  variant = "stacked",
54
55
  legend = false,
56
+ legendAlign = "left",
55
57
  valueLabels = false,
56
58
  height = 200,
57
59
  yAxisWidth = 48,
@@ -168,7 +170,7 @@ function BarChart({
168
170
  no data in range
169
171
  </div>
170
172
  ) : (
171
- <ResponsiveContainer width="100%" height="100%">
173
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
172
174
  <RechartsBarChart
173
175
  data={data as Record<string, string | number>[]}
174
176
  stackOffset={variant === "expand" ? "expand" : "none"}
@@ -198,7 +200,13 @@ function BarChart({
198
200
  </defs>
199
201
 
200
202
  <CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
201
- <XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
203
+ <XAxis
204
+ dataKey={index}
205
+ {...axis}
206
+ tick={makeXAxisTick(theme)}
207
+ interval="preserveStartEnd"
208
+ minTickGap={44}
209
+ />
202
210
  <YAxis
203
211
  {...axis}
204
212
  width={yAxisWidth}
@@ -301,7 +309,7 @@ function BarChart({
301
309
  )}
302
310
  </div>
303
311
 
304
- {legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
312
+ {legend && !isEmpty && <ChartLegend items={legendItems} align={legendAlign} insetLeft={yAxisWidth} />}
305
313
  </div>
306
314
  );
307
315
  }
@@ -15,15 +15,22 @@ export interface BarListProps {
15
15
  dataKey?: string;
16
16
  maxItems?: number;
17
17
  palette?: ChartPaletteName;
18
+ /** Renders bars in a single semantic hue (e.g. red for errors) rather than the palette ramp. */
19
+ semantic?: "error" | "warning" | "success";
18
20
  loading?: boolean;
19
21
  valueFormatter?: (value: number) => string;
20
22
  labelFormatter?: (label: string | number) => string;
21
23
  className?: string;
22
24
  }
23
25
 
26
+ const SEMANTIC_KEY = { error: "destructive", warning: "warning", success: "success" } as const;
27
+
24
28
  const PLACEHOLDER_HEIGHT = 168;
25
29
  // Cap the ramp off its palest end so low-rank bars stay legible on a light card.
26
30
  const RAMP_CEILING = 0.8;
31
+ // Semantic bars keep a single hue: the lightest bar still holds this much colour
32
+ // (mixed toward white) so a long error list never fades to near-invisible.
33
+ const SEMANTIC_FLOOR = 0.62;
27
34
  const IN_FILL_SHADOW = "0 1px 2px rgb(0 0 0 / 0.28)";
28
35
 
29
36
  function BarList({
@@ -32,12 +39,14 @@ function BarList({
32
39
  dataKey = "value",
33
40
  maxItems,
34
41
  palette,
42
+ semantic,
35
43
  loading = false,
36
44
  valueFormatter = (value) => value.toLocaleString("en-US"),
37
45
  labelFormatter,
38
46
  className,
39
47
  }: BarListProps) {
40
- const { paletteName } = useChartContext(palette);
48
+ const { paletteName, theme } = useChartContext(palette);
49
+ const accent = semantic ? theme[SEMANTIC_KEY[semantic]] : null;
41
50
  const reducedMotion = useReducedMotion();
42
51
  const [mounted, setMounted] = React.useState(false);
43
52
  const [active, setActive] = React.useState<number | null>(null);
@@ -55,11 +64,17 @@ function BarList({
55
64
  }));
56
65
  mapped.sort((lower, upper) => upper.value - lower.value);
57
66
  const capped = maxItems ? mapped.slice(0, maxItems) : mapped;
58
- return capped.map((row, rank) => ({
59
- ...row,
60
- color: rampColor(paletteName, (capped.length > 1 ? rank / (capped.length - 1) : 0) * RAMP_CEILING),
61
- }));
62
- }, [data, index, dataKey, maxItems, paletteName]);
67
+ return capped.map((row, rank) => {
68
+ const rankFraction = capped.length > 1 ? rank / (capped.length - 1) : 0;
69
+ const accentWeight = Math.round((1 - rankFraction * (1 - SEMANTIC_FLOOR)) * 100);
70
+ return {
71
+ ...row,
72
+ color: accent
73
+ ? `color-mix(in oklab, ${accent} ${accentWeight}%, white)`
74
+ : rampColor(paletteName, rankFraction * RAMP_CEILING),
75
+ };
76
+ });
77
+ }, [data, index, dataKey, maxItems, paletteName, accent]);
63
78
 
64
79
  const maxValue = rows.reduce((max, row) => (row.value > max ? row.value : max), 0);
65
80
  const isEmpty = rows.length === 0;
@@ -95,7 +110,7 @@ function BarList({
95
110
  }
96
111
 
97
112
  return (
98
- <div data-slot="bar-list" className={cn("flex w-full flex-col gap-2", className)}>
113
+ <div data-slot="bar-list" className={cn("flex w-full flex-col", className)}>
99
114
  {rows.map((row, slot) => {
100
115
  const fraction = maxValue > 0 ? row.value / maxValue : 0;
101
116
  const fillWidth = !reducedMotion && !mounted ? "0%" : `${fraction * 100}%`;
@@ -107,19 +122,20 @@ function BarList({
107
122
  key={row.name}
108
123
  onMouseEnter={() => setActive(slot)}
109
124
  onMouseLeave={() => setActive(null)}
110
- className="flex items-center gap-3"
125
+ className="flex items-center gap-3 py-1"
111
126
  >
112
127
  <div className="relative h-[30px] flex-1 overflow-hidden rounded-md bg-muted">
113
128
  <span className="pointer-events-none absolute inset-x-3 top-1/2 z-0 -translate-y-1/2 truncate type-text-xs font-medium text-foreground">
114
129
  {formatName(row.name)}
115
130
  </span>
116
131
  <div
117
- className="absolute inset-y-0 left-0 z-10 overflow-hidden rounded-md motion-safe:transition-[width,opacity] motion-safe:duration-700 motion-safe:ease-out"
132
+ className="absolute inset-y-0 left-0 z-10 overflow-hidden rounded-md"
118
133
  style={{
119
134
  width: fillWidth,
120
135
  background: `linear-gradient(90deg, ${row.color}, color-mix(in oklab, ${row.color} 82%, transparent))`,
121
136
  boxShadow: `inset 0 0 0 1px ${row.color}`,
122
137
  opacity: dimmed ? 0.45 : 1,
138
+ transition: reducedMotion ? undefined : "width 700ms ease-out",
123
139
  }}
124
140
  >
125
141
  <span
@@ -135,7 +151,7 @@ function BarList({
135
151
  </div>
136
152
  </div>
137
153
  <span
138
- className="w-16 shrink-0 text-right font-mono text-text-xs tabular-nums motion-safe:transition-colors"
154
+ className="min-w-[4rem] shrink-0 whitespace-nowrap text-right font-mono text-text-xs tabular-nums motion-safe:transition-colors"
139
155
  style={{ color: isActive ? row.color : undefined }}
140
156
  >
141
157
  {isActive ? formatShare(total > 0 ? row.value / total : 0) : valueFormatter(row.value)}
@@ -12,7 +12,7 @@ export interface ChartCardProps extends Omit<React.ComponentProps<"section">, "t
12
12
  title?: React.ReactNode;
13
13
  description?: React.ReactNode;
14
14
  action?: React.ReactNode;
15
- accent?: "top" | "left";
15
+ accent?: "top" | "left" | "none";
16
16
  }
17
17
 
18
18
  function ChartCard({
@@ -40,11 +40,13 @@ function ChartCard({
40
40
  )}
41
41
  {...props}
42
42
  >
43
- <span
44
- aria-hidden
45
- className={cn("absolute", isLeft ? "inset-y-0 left-0 w-[3px]" : "inset-x-0 top-0 h-[3px]")}
46
- style={{ background: lead }}
47
- />
43
+ {accent !== "none" && (
44
+ <span
45
+ aria-hidden
46
+ className={cn("absolute", isLeft ? "inset-y-0 left-0 w-[3px]" : "inset-x-0 top-0 h-[3px]")}
47
+ style={{ background: lead }}
48
+ />
49
+ )}
48
50
  {(kicker || title || action) && (
49
51
  <header className="mb-4 flex items-start justify-between gap-4">
50
52
  <div className="flex flex-col gap-1">
@@ -6,6 +6,8 @@ import { type ChartTheme, useChartTheme } from "../hooks/use-chart-theme";
6
6
  import { CHART_PALETTES, type ChartPaletteName } from "../lib/chart-palette";
7
7
  import { cn } from "../lib/cn";
8
8
 
9
+ export type { ChartPaletteName } from "../lib/chart-palette";
10
+
9
11
  interface ChartContextValue {
10
12
  palette: readonly string[];
11
13
  paletteName: ChartPaletteName;
@@ -10,8 +10,12 @@ export interface ChartLegendItem {
10
10
 
11
11
  export interface ChartLegendProps extends React.HTMLAttributes<HTMLDivElement> {
12
12
  items: ChartLegendItem[];
13
+ align?: "left" | "center" | "right";
14
+ insetLeft?: number;
13
15
  }
14
16
 
17
+ const ALIGN_CLASS = { left: "justify-start", center: "justify-center", right: "justify-end" } as const;
18
+
15
19
  function Swatch({ color, dashed }: { color: string; dashed?: boolean }) {
16
20
  return (
17
21
  <span
@@ -22,9 +26,13 @@ function Swatch({ color, dashed }: { color: string; dashed?: boolean }) {
22
26
  );
23
27
  }
24
28
 
25
- function ChartLegend({ items, className, ...props }: ChartLegendProps) {
29
+ function ChartLegend({ items, align = "left", insetLeft, className, style, ...props }: ChartLegendProps) {
26
30
  return (
27
- <div className={cn("flex flex-wrap gap-x-4 gap-y-1.5", className)} {...props}>
31
+ <div
32
+ className={cn("flex flex-wrap gap-x-4 gap-y-1.5", ALIGN_CLASS[align], className)}
33
+ style={{ paddingLeft: align === "left" ? insetLeft : undefined, ...style }}
34
+ {...props}
35
+ >
28
36
  {items.map((item) => (
29
37
  <span
30
38
  key={item.name}
@@ -99,7 +99,7 @@ function DonutChart({
99
99
  ) : (
100
100
  <div className="flex flex-col items-center gap-5 @md:flex-row">
101
101
  <div className="relative shrink-0" style={{ width: height, height }}>
102
- <ResponsiveContainer width="100%" height="100%">
102
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
103
103
  <RechartsPieChart>
104
104
  <defs>
105
105
  {slices.map((slice, slot) => (
@@ -142,7 +142,7 @@ function DonutChart({
142
142
  </ResponsiveContainer>
143
143
 
144
144
  <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 text-center">
145
- <span className="max-w-[72%] truncate font-mono text-[10px] text-quaternary-foreground uppercase tracking-[0.18em]">
145
+ <span className="max-w-[52%] truncate font-mono text-[10px] text-quaternary-foreground uppercase tracking-[0.18em]">
146
146
  {centerTitle}
147
147
  </span>
148
148
  <span className="font-mono font-semibold text-[28px] text-foreground leading-none tabular-nums">
@@ -176,7 +176,7 @@ function DonutChart({
176
176
  active === slot ? "bg-muted/50" : "bg-transparent",
177
177
  )}
178
178
  >
179
- <div className="flex items-center justify-between gap-3">
179
+ <div className="flex items-center justify-between gap-2">
180
180
  <span className="inline-flex min-w-0 items-center gap-2 text-muted-foreground">
181
181
  <span
182
182
  aria-hidden
@@ -185,11 +185,11 @@ function DonutChart({
185
185
  />
186
186
  <span className="truncate">{formatName(slice.name)}</span>
187
187
  </span>
188
- <span className="flex shrink-0 items-center gap-3 font-mono tabular-nums">
189
- <span className="min-w-[3.5rem] text-right font-semibold text-foreground">
188
+ <span className="flex shrink-0 items-center gap-2 font-mono tabular-nums">
189
+ <span className="min-w-[2.25rem] text-right font-semibold text-foreground">
190
190
  {valueFormatter(slice.value)}
191
191
  </span>
192
- <span className="w-10 text-right text-quaternary-foreground">
192
+ <span className="w-9 text-right text-quaternary-foreground">
193
193
  {formatShare(slice.value / total)}
194
194
  </span>
195
195
  </span>
@@ -106,7 +106,7 @@ function FormLabel({ className, required, tooltip, children, ...props }: FormLab
106
106
  {children}
107
107
  </Label>
108
108
  {required && (
109
- <span aria-hidden className="type-text-sm font-medium text-required">
109
+ <span aria-hidden className="type-text-sm font-medium text-required leading-none">
110
110
  *
111
111
  </span>
112
112
  )}
@@ -17,21 +17,26 @@ export interface HeatmapChartProps {
17
17
  palette?: ChartPaletteName;
18
18
  xLabels?: readonly string[];
19
19
  yLabels?: readonly string[];
20
+ showAllXLabels?: boolean;
20
21
  highlightPeak?: boolean;
21
22
  loading?: boolean;
22
23
  valueFormatter?: (value: number) => string;
23
24
  xTickFormatter?: (label: string) => string;
24
25
  yTickFormatter?: (label: string) => string;
26
+ tooltipMetrics?: ReadonlyArray<{ key: string; label: string; format?: (value: number) => string }>;
25
27
  ariaLabel?: string;
26
28
  className?: string;
27
29
  }
28
30
 
31
+ type CellRecord = Record<string, string | number | null | undefined>;
32
+
29
33
  interface HoverState {
30
34
  x: number;
31
35
  y: number;
32
36
  rowLabel: string;
33
37
  colLabel: string;
34
38
  value: number;
39
+ record: CellRecord | null;
35
40
  }
36
41
 
37
42
  const PLACEHOLDER_HEIGHT = 168;
@@ -71,11 +76,13 @@ function HeatmapChart({
71
76
  palette,
72
77
  xLabels,
73
78
  yLabels,
79
+ showAllXLabels = false,
74
80
  highlightPeak = true,
75
81
  loading = false,
76
82
  valueFormatter = (value) => value.toLocaleString("en-US"),
77
83
  xTickFormatter,
78
84
  yTickFormatter,
85
+ tooltipMetrics,
79
86
  ariaLabel = "Activity heatmap",
80
87
  className,
81
88
  }: HeatmapChartProps) {
@@ -91,19 +98,23 @@ function HeatmapChart({
91
98
  const columns = xLabels ? [...xLabels] : uniqueInOrder(data.map((row) => String(row[xKey] ?? "")));
92
99
  const rows = yLabels ? [...yLabels] : uniqueInOrder(data.map((row) => String(row[yKey] ?? "")));
93
100
  const valueAt = new Map<string, number>();
101
+ const recordAt = new Map<string, CellRecord>();
94
102
  for (const row of data) {
95
- valueAt.set(pairKey(String(row[yKey] ?? ""), String(row[xKey] ?? "")), Number(row[dataKey]) || 0);
103
+ const key = pairKey(String(row[yKey] ?? ""), String(row[xKey] ?? ""));
104
+ valueAt.set(key, Number(row[dataKey]) || 0);
105
+ recordAt.set(key, row);
96
106
  }
97
107
  let maxValue = 0;
98
108
  let peakKey = "";
99
109
  const cells = rows.flatMap((rowLabel, rowIndex) =>
100
110
  columns.map((colLabel, colIndex) => {
101
- const value = valueAt.get(pairKey(rowLabel, colLabel)) ?? 0;
111
+ const key = pairKey(rowLabel, colLabel);
112
+ const value = valueAt.get(key) ?? 0;
102
113
  if (value > maxValue) {
103
114
  maxValue = value;
104
- peakKey = pairKey(rowLabel, colLabel);
115
+ peakKey = key;
105
116
  }
106
- return { rowLabel, colLabel, rowIndex, colIndex, value };
117
+ return { rowLabel, colLabel, rowIndex, colIndex, value, record: recordAt.get(key) ?? null };
107
118
  }),
108
119
  );
109
120
  return { columns, rows, cells, maxValue, peakKey };
@@ -144,7 +155,7 @@ function HeatmapChart({
144
155
  const ramp = heatRamp(paletteName, theme.isDark ? HEAT_EMPTY.dark : HEAT_EMPTY.light);
145
156
  const totalWidth = PAD.left + columns.length * CELL + (columns.length - 1) * GAP + PAD.right;
146
157
  const totalHeight = PAD.top + rows.length * CELL + (rows.length - 1) * GAP + PAD.bottom;
147
- const xStride = Math.ceil(columns.length / MAX_X_TICKS);
158
+ const xStride = showAllXLabels ? 1 : Math.ceil(columns.length / MAX_X_TICKS);
148
159
  const cellX = (col: number) => PAD.left + col * (CELL + GAP);
149
160
  const cellY = (row: number) => PAD.top + row * (CELL + GAP);
150
161
  const formatX = (label: string) => (xTickFormatter ? xTickFormatter(label) : label);
@@ -247,6 +258,7 @@ function HeatmapChart({
247
258
  rowLabel: cell.rowLabel,
248
259
  colLabel: cell.colLabel,
249
260
  value: cell.value,
261
+ record: cell.record,
250
262
  })
251
263
  }
252
264
  />
@@ -264,19 +276,51 @@ function HeatmapChart({
264
276
  <p className="mb-1.5 font-mono text-[10px] text-quaternary-foreground uppercase tracking-wider">
265
277
  {formatY(hovered.rowLabel)} · {formatX(hovered.colLabel)}
266
278
  </p>
267
- <div className="flex items-center justify-between gap-4 text-text-xs">
268
- <span className="inline-flex items-center gap-2 text-muted-foreground">
269
- <span
270
- aria-hidden
271
- className="size-2 shrink-0 rounded-[2px]"
272
- style={{ background: heatColor(ramp, hovered.value / maxValue) }}
273
- />
274
- activity
275
- </span>
276
- <span className="font-mono font-semibold tabular-nums text-foreground">
277
- {valueFormatter(hovered.value)}
278
- </span>
279
- </div>
279
+ {tooltipMetrics && tooltipMetrics.length > 0 ? (
280
+ <div className="flex flex-col gap-1">
281
+ {tooltipMetrics.map((metric) => {
282
+ const raw = hovered.record ? Number(hovered.record[metric.key]) || 0 : 0;
283
+ const isActive = metric.key === dataKey;
284
+ return (
285
+ <div key={metric.key} className="flex items-center justify-between gap-4 text-text-xs">
286
+ <span className="inline-flex items-center gap-2 text-muted-foreground">
287
+ <span
288
+ aria-hidden
289
+ className="size-2 shrink-0 rounded-[2px]"
290
+ style={{
291
+ background: isActive ? heatColor(ramp, hovered.value / maxValue) : theme.mutedForeground,
292
+ opacity: isActive ? 1 : 0.35,
293
+ }}
294
+ />
295
+ {metric.label}
296
+ </span>
297
+ <span
298
+ className={cn(
299
+ "font-mono tabular-nums",
300
+ isActive ? "font-semibold text-foreground" : "text-muted-foreground",
301
+ )}
302
+ >
303
+ {(metric.format ?? valueFormatter)(raw)}
304
+ </span>
305
+ </div>
306
+ );
307
+ })}
308
+ </div>
309
+ ) : (
310
+ <div className="flex items-center justify-between gap-4 text-text-xs">
311
+ <span className="inline-flex items-center gap-2 text-muted-foreground">
312
+ <span
313
+ aria-hidden
314
+ className="size-2 shrink-0 rounded-[2px]"
315
+ style={{ background: heatColor(ramp, hovered.value / maxValue) }}
316
+ />
317
+ activity
318
+ </span>
319
+ <span className="font-mono font-semibold tabular-nums text-foreground">
320
+ {valueFormatter(hovered.value)}
321
+ </span>
322
+ </div>
323
+ )}
280
324
  </div>,
281
325
  document.body,
282
326
  )}