@asteby/metacore-runtime-react 18.17.0 → 18.17.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 18.17.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 714cc34: Dashboard: masonry (balanced CSS columns) layout — no blank cells; charts fixed-height, compact stat cards
8
+
9
+ ## 18.17.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 211484b: Dashboard grid: compact KPI stat cards, dense fixed-row packing (charts span 2 rows), charts fill card height
14
+
3
15
  ## 18.17.0
4
16
 
5
17
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAW1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,qBA4IpB"}
1
+ {"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAW1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,qBAkJpB"}
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
9
9
  import { cn } from '@asteby/metacore-ui/lib';
10
10
  import { useCan, usePermissionsActive } from './permissions-context';
11
11
  import { DynamicIcon } from './dynamic-icon';
12
- import { WidgetRenderer, WidgetSkeleton, SIZE_CLASS } from './widgets/widget-renderer';
12
+ import { WidgetRenderer, WidgetSkeleton, isTallWidget } from './widgets/widget-renderer';
13
13
  const DEFAULT_STRINGS = {
14
14
  emptyTitle: 'Your dashboard is empty',
15
15
  emptyDescription: 'Install an addon with dashboard widgets to start seeing metrics here.',
@@ -110,18 +110,30 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
110
110
  if (visibleGroups.length === 0) {
111
111
  return (_jsxs("div", { "data-testid": "dashboard-empty", className: cn('flex min-h-[40vh] flex-col items-center justify-center rounded-xl border border-dashed border-border/60 p-10 text-center', className), children: [_jsx("div", { className: "mb-4 flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground", children: _jsx(DynamicIcon, { name: "LayoutDashboard", className: "size-7" }) }), _jsx("h3", { className: "text-base font-semibold text-foreground", children: tr(undefined, s.emptyTitle) || s.emptyTitle }), _jsx("p", { className: "mt-1 max-w-sm text-sm text-muted-foreground", children: s.emptyDescription })] }));
112
112
  }
113
- return (_jsx("div", { "data-testid": "dashboard-grid", className: cn('flex flex-col gap-8', className), children: visibleGroups.map((group) => (_jsxs("section", { className: "flex flex-col gap-3", children: [group.title && (_jsx("h2", { className: "text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: tr(group.title, group.title) })), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", children: group.widgets.map((spec) => {
114
- const size = spec.size ?? 'sm';
115
- return (_jsx("div", { className: SIZE_CLASS[size], children: loading ? (_jsx(WidgetSkeleton, { spec: {
116
- ...spec,
117
- title: tr(spec.title, spec.title),
118
- subtitle: tr(spec.subtitle, spec.subtitle),
119
- } })) : (_jsx(WidgetRenderer, { spec: {
120
- ...spec,
121
- title: tr(spec.title, spec.title),
122
- subtitle: tr(spec.subtitle, spec.subtitle),
123
- }, data: data?.[spec.key], locale: locale, currency: currency, emptyText: spec.empty
124
- ? tr(spec.empty, spec.empty)
125
- : s.widgetEmpty, errorText: s.widgetError })) }, spec.key));
126
- }) })] }, group.title || '__default'))) }));
113
+ // ONE unified dense grid across every group. Per-group sections used to
114
+ // break the layout into rows, so a lone-widget group (e.g. a single KPI)
115
+ // left the rest of its row blank. Flattening + `grid-flow-row-dense`
116
+ // backfills those holes; ordering compact KPIs before charts makes the top
117
+ // read as a metric band and the charts mosaic below it. No blank space.
118
+ const ordered = React.useMemo(() => {
119
+ const flat = visibleGroups.flatMap((g) => g.widgets);
120
+ return flat
121
+ .map((w, i) => ({ w, i }))
122
+ .sort((a, b) => (isTallWidget(a.w) ? 1 : 0) - (isTallWidget(b.w) ? 1 : 0) ||
123
+ a.i - b.i)
124
+ .map((x) => x.w);
125
+ }, [visibleGroups]);
126
+ return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
127
+ // Masonry: balanced CSS columns. Cards take their natural height
128
+ // (compact stats, taller charts) and flow to equalize column
129
+ // height, so there are NO blank cells — the gaps a fixed grid
130
+ // leaves around mixed 1×1 / 2×2 tiles simply don't exist here.
131
+ 'columns-1 gap-4 md:columns-2 xl:columns-3', className), children: ordered.map((spec) => {
132
+ const rspec = {
133
+ ...spec,
134
+ title: tr(spec.title, spec.title),
135
+ subtitle: tr(spec.subtitle, spec.subtitle),
136
+ };
137
+ return (_jsx("div", { className: "mb-4 break-inside-avoid", children: loading ? (_jsx(WidgetSkeleton, { spec: rspec })) : (_jsx(WidgetRenderer, { spec: rspec, data: data?.[spec.key], locale: locale, currency: currency, emptyText: spec.empty ? tr(spec.empty, spec.empty) : s.widgetEmpty, errorText: s.widgetError })) }, spec.key));
138
+ }) }));
127
139
  }
@@ -1 +1 @@
1
- {"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../../src/widgets/renderers.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAkB9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAazE,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE,mBAAmB,CAAA;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAA;CACpB;AAiCD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBA2B9C;AAGD,wBAAgB,SAAS,CAAC,CAAC,EAAE,iBAAiB,qBAsC7C;AAgDD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAE9C;AACD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAE9C;AAqDD,wBAAgB,SAAS,CAAC,CAAC,EAAE,iBAAiB,qBAE7C;AACD,wBAAgB,WAAW,CAAC,CAAC,EAAE,iBAAiB,qBAE/C;AAGD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAqC9C;AAKD,wBAAgB,cAAc,CAAC,CAAC,EAAE,iBAAiB,qBAkClD"}
1
+ {"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../../src/widgets/renderers.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAkB9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAazE,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE,mBAAmB,CAAA;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAA;CACpB;AA8CD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBA+B9C;AAGD,wBAAgB,SAAS,CAAC,CAAC,EAAE,iBAAiB,qBAsC7C;AAgDD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAE9C;AACD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAE9C;AAuDD,wBAAgB,SAAS,CAAC,CAAC,EAAE,iBAAiB,qBAE7C;AACD,wBAAgB,WAAW,CAAC,CAAC,EAAE,iBAAiB,qBAE/C;AAGD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAqC9C;AAKD,wBAAgB,cAAc,CAAC,CAAC,EAAE,iBAAiB,qBAkClD"}
@@ -5,7 +5,14 @@ import { WidgetCard, DeltaChip, WidgetEmpty } from './widget-card';
5
5
  import { accentClasses, paletteColor, formatWidgetValue, formatAxisTick, formatDelta, CHART_GRID, CHART_MUTED, } from './widget-format';
6
6
  const fmtCtx = (spec, locale, currency) => ({ format: spec.format, locale, currency });
7
7
  const hasSeries = (d) => Array.isArray(d?.series) && d.series.length > 0;
8
- const CHART_HEIGHT = 132;
8
+ // Fixed-height chart body. In the masonry (CSS columns) layout cards take their
9
+ // natural height, so charts need a definite height for ResponsiveContainer to
10
+ // resolve. This height + the card chrome gives every chart card a consistent,
11
+ // taller footprint that balances cleanly against compact stat cards.
12
+ const CHART_H = 'h-[190px]';
13
+ function ChartArea({ children }) {
14
+ return (_jsx("div", { className: cn(CHART_H, 'w-full'), children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: children }) }));
15
+ }
9
16
  // Compact recharts tooltip styled with theme tokens.
10
17
  function ChartTooltip({ ctx }) {
11
18
  return (_jsx(Tooltip, { cursor: { fill: 'var(--muted, rgba(148,163,184,0.12))', opacity: 0.4 }, contentStyle: {
@@ -23,20 +30,20 @@ export function StatWidget(p) {
23
30
  const value = p.data?.value;
24
31
  const delta = p.data?.delta;
25
32
  const hasValue = typeof value === 'number' && !Number.isNaN(value);
26
- return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, headerExtra: typeof delta === 'number' ? (_jsx(DeltaChip, { delta: delta, text: formatDelta(delta, p.locale) })) : undefined, children: hasValue ? (_jsx("div", { className: "text-3xl font-semibold tabular-nums tracking-tight text-foreground", children: formatWidgetValue(value, ctx) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
33
+ return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, headerExtra: typeof delta === 'number' ? (_jsx(DeltaChip, { delta: delta, text: formatDelta(delta, p.locale) })) : undefined, children: hasValue ? (_jsx("div", { className: "flex min-h-[2.5rem] items-end", children: _jsx("div", { className: "text-[2rem] font-semibold leading-none tabular-nums tracking-tight text-foreground", children: formatWidgetValue(value, ctx) }) })) : (_jsx("div", { className: "flex min-h-[2.5rem] items-center text-sm text-muted-foreground", children: p.emptyText })) }));
27
34
  }
28
35
  // --- bar ------------------------------------------------------------------
29
36
  export function BarWidget(p) {
30
37
  const ctx = fmtCtx(p.spec, p.locale, p.currency);
31
38
  const a = accentClasses(p.spec.accent);
32
- return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(ResponsiveContainer, { width: "100%", height: CHART_HEIGHT, children: _jsxs(BarChart, { data: p.data.series, margin: { top: 4, right: 4, left: -16, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Bar, { dataKey: "value", fill: a.chartVar, radius: [4, 4, 0, 0], maxBarSize: 40 })] }) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
39
+ return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(ChartArea, { children: _jsxs(BarChart, { data: p.data.series, margin: { top: 4, right: 4, left: -16, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Bar, { dataKey: "value", fill: a.chartVar, radius: [4, 4, 0, 0], maxBarSize: 48 })] }) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
33
40
  }
34
41
  // --- line / area (shared) -------------------------------------------------
35
42
  function TimeSeriesWidget(p) {
36
43
  const ctx = fmtCtx(p.spec, p.locale, p.currency);
37
44
  const a = accentClasses(p.spec.accent);
38
45
  const gradId = `wg-grad-${p.spec.key}`;
39
- return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(ResponsiveContainer, { width: "100%", height: CHART_HEIGHT, children: p.variant === 'area' ? (_jsxs(AreaChart, { data: p.data.series, margin: { top: 4, right: 6, left: -16, bottom: 0 }, children: [_jsx("defs", { children: _jsxs("linearGradient", { id: gradId, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: a.chartVar, stopOpacity: 0.35 }), _jsx("stop", { offset: "100%", stopColor: a.chartVar, stopOpacity: 0.02 })] }) }), _jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Area, { type: "monotone", dataKey: "value", stroke: a.chartVar, strokeWidth: 2, fill: `url(#${gradId})` })] })) : (_jsxs(LineChart, { data: p.data.series, margin: { top: 4, right: 6, left: -16, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Line, { type: "monotone", dataKey: "value", stroke: a.chartVar, strokeWidth: 2, dot: false, activeDot: { r: 4 } })] })) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
46
+ return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(ChartArea, { children: p.variant === 'area' ? (_jsxs(AreaChart, { data: p.data.series, margin: { top: 4, right: 6, left: -16, bottom: 0 }, children: [_jsx("defs", { children: _jsxs("linearGradient", { id: gradId, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: a.chartVar, stopOpacity: 0.35 }), _jsx("stop", { offset: "100%", stopColor: a.chartVar, stopOpacity: 0.02 })] }) }), _jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Area, { type: "monotone", dataKey: "value", stroke: a.chartVar, strokeWidth: 2, fill: `url(#${gradId})` })] })) : (_jsxs(LineChart, { data: p.data.series, margin: { top: 4, right: 6, left: -16, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Line, { type: "monotone", dataKey: "value", stroke: a.chartVar, strokeWidth: 2, dot: false, activeDot: { r: 4 } })] })) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
40
47
  }
41
48
  export function LineWidget(p) {
42
49
  return _jsx(TimeSeriesWidget, { ...p, variant: "line" });
@@ -47,7 +54,7 @@ export function AreaWidget(p) {
47
54
  // --- pie / donut (shared) -------------------------------------------------
48
55
  function CircularWidget(p) {
49
56
  const ctx = fmtCtx(p.spec, p.locale, p.currency);
50
- return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ResponsiveContainer, { width: "50%", height: CHART_HEIGHT, children: _jsxs(PieChart, { children: [_jsx(ChartTooltip, { ctx: ctx }), _jsx(Pie, { data: p.data.series, dataKey: "value", nameKey: "label", innerRadius: p.variant === 'donut' ? 32 : 0, outerRadius: 56, paddingAngle: p.variant === 'donut' ? 2 : 0, stroke: "var(--background, #fff)", strokeWidth: 2, children: p.data.series.map((_, i) => (_jsx(Cell, { fill: paletteColor(i) }, i))) })] }) }), _jsx("ul", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: p.data.series.slice(0, 6).map((pt, i) => (_jsxs("li", { className: "flex items-center gap-2 text-xs", children: [_jsx("span", { className: "size-2.5 shrink-0 rounded-sm", style: { background: paletteColor(i) } }), _jsx("span", { className: "truncate text-muted-foreground", children: pt.label }), _jsx("span", { className: "ml-auto shrink-0 font-medium tabular-nums text-foreground", children: formatWidgetValue(pt.value, ctx) })] }, pt.key))) })] })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
57
+ return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsxs("div", { className: cn(CHART_H, 'flex items-center gap-3'), children: [_jsx("div", { className: "h-full min-h-0 w-[46%] shrink-0", children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(PieChart, { children: [_jsx(ChartTooltip, { ctx: ctx }), _jsx(Pie, { data: p.data.series, dataKey: "value", nameKey: "label", innerRadius: p.variant === 'donut' ? '55%' : 0, outerRadius: "92%", paddingAngle: p.variant === 'donut' ? 2 : 0, stroke: "var(--background, #fff)", strokeWidth: 2, children: p.data.series.map((_, i) => (_jsx(Cell, { fill: paletteColor(i) }, i))) })] }) }) }), _jsx("ul", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: p.data.series.slice(0, 6).map((pt, i) => (_jsxs("li", { className: "flex items-center gap-2 text-xs", children: [_jsx("span", { className: "size-2.5 shrink-0 rounded-sm", style: { background: paletteColor(i) } }), _jsx("span", { className: "truncate text-muted-foreground", children: pt.label }), _jsx("span", { className: "ml-auto shrink-0 font-medium tabular-nums text-foreground", children: formatWidgetValue(pt.value, ctx) })] }, pt.key))) })] })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
51
58
  }
52
59
  export function PieWidget(p) {
53
60
  return _jsx(CircularWidget, { ...p, variant: "pie" });
@@ -12,7 +12,7 @@ export function WidgetCard({ title, subtitle, icon, accent, headerExtra, childre
12
12
  const showIcon = icon && isLucideIconName(icon);
13
13
  return (_jsxs(Card, { ...rest, className: cn(
14
14
  // base: subtle border, ring on hover, gentle lift, mount fade-in
15
- 'group/widget relative flex h-full flex-col overflow-hidden', 'border-border/60 transition-all duration-200', 'hover:border-border hover:ring-1 hover:ring-ring/30 hover:shadow-sm', 'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-500', className), children: [_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-3 space-y-0 pb-2", children: [_jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [showIcon && (_jsx("span", { className: cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.chip), children: _jsx(DynamicIcon, { name: icon, className: "size-[18px]" }) })), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium leading-tight text-foreground", children: title }), subtitle && (_jsx("div", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: subtitle }))] })] }), headerExtra && _jsx("div", { className: "shrink-0", children: headerExtra })] }), _jsx(CardContent, { className: "flex flex-1 flex-col justify-end pt-1", children: children })] }));
15
+ 'group/widget relative flex h-full flex-col overflow-hidden', 'border-border/60 transition-all duration-200', 'hover:border-border hover:ring-1 hover:ring-ring/30 hover:shadow-sm', 'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-500', className), children: [_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-3 space-y-0 pb-2", children: [_jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [showIcon && (_jsx("span", { className: cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.chip), children: _jsx(DynamicIcon, { name: icon, className: "size-[18px]" }) })), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium leading-tight text-foreground", children: title }), subtitle && (_jsx("div", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: subtitle }))] })] }), headerExtra && _jsx("div", { className: "shrink-0", children: headerExtra })] }), _jsx(CardContent, { className: "flex min-h-0 flex-1 flex-col pt-1", children: children })] }));
16
16
  }
17
17
  /** Delta chip: green up / red down, neutral on zero. `text` is preformatted. */
18
18
  export function DeltaChip({ delta, text }) {
@@ -2,8 +2,18 @@ import * as React from 'react';
2
2
  import type { DashboardWidgetSpec, WidgetData, WidgetSize } from '../dashboard-types';
3
3
  /** Maps a widget size to its column span in the 4-col grid. */
4
4
  export declare const SIZE_SPAN: Record<WidgetSize, number>;
5
- /** Tailwind col-span class per size (static → scanned in this package build). */
5
+ /** Tailwind col-span class per size (static → scanned in this package build).
6
+ * Grid is 2 cols on mobile, 4 on lg: sm=quarter, md=half, lg=¾, full=row. */
6
7
  export declare const SIZE_CLASS: Record<WidgetSize, string>;
8
+ /** Default footprint when a spec omits `size`: charts go half-width, stats a
9
+ * quarter — so a tablero of mixed widgets packs densely without manual sizing. */
10
+ export declare function defaultSize(spec: DashboardWidgetSpec): WidgetSize;
11
+ /** Combined col + row span for a widget's grid cell. Row-span drives the
12
+ * height contrast (chart=2, stat=1) that makes the layout feel designed. */
13
+ export declare function spanClass(spec: DashboardWidgetSpec): string;
14
+ /** True for chart/list widgets that occupy 2 rows. Used to order compact KPIs
15
+ * before charts in the unified grid so the top reads as a metric band. */
16
+ export declare function isTallWidget(spec: DashboardWidgetSpec): boolean;
7
17
  export interface WidgetRendererProps {
8
18
  spec: DashboardWidgetSpec;
9
19
  data?: WidgetData;
@@ -1 +1 @@
1
- {"version":3,"file":"widget-renderer.d.ts","sourceRoot":"","sources":["../../src/widgets/widget-renderer.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAcrF,+DAA+D;AAC/D,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKhD,CAAA;AAED,iFAAiF;AACjF,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKjD,CAAA;AAiDD,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,mBAAmB,CAAA;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAA;IACjB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,SAAS,EACT,SAAS,GACZ,EAAE,mBAAmB,qBAkDrB;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,qBAqBrE"}
1
+ {"version":3,"file":"widget-renderer.d.ts","sourceRoot":"","sources":["../../src/widgets/widget-renderer.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAcrF,+DAA+D;AAC/D,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKhD,CAAA;AAED;6EAC6E;AAC7E,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKjD,CAAA;AAMD;kFACkF;AAClF,wBAAgB,WAAW,CAAC,IAAI,EAAE,mBAAmB,GAAG,UAAU,CAGjE;AAED;4EAC4E;AAC5E,wBAAgB,SAAS,CAAC,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAG3D;AAED;0EAC0E;AAC1E,wBAAgB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAE/D;AAiDD,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,mBAAmB,CAAA;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAA;IACjB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,SAAS,EACT,SAAS,GACZ,EAAE,mBAAmB,qBAkDrB;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,qBAqBrE"}
@@ -13,13 +13,35 @@ export const SIZE_SPAN = {
13
13
  lg: 3,
14
14
  full: 4,
15
15
  };
16
- /** Tailwind col-span class per size (static → scanned in this package build). */
16
+ /** Tailwind col-span class per size (static → scanned in this package build).
17
+ * Grid is 2 cols on mobile, 4 on lg: sm=quarter, md=half, lg=¾, full=row. */
17
18
  export const SIZE_CLASS = {
18
- sm: 'sm:col-span-2 lg:col-span-1',
19
- md: 'sm:col-span-2 lg:col-span-2',
20
- lg: 'sm:col-span-2 lg:col-span-3',
21
- full: 'sm:col-span-2 lg:col-span-4',
19
+ sm: 'col-span-1 lg:col-span-1',
20
+ md: 'col-span-2 lg:col-span-2',
21
+ lg: 'col-span-2 lg:col-span-3',
22
+ full: 'col-span-2 lg:col-span-4',
22
23
  };
24
+ /** Kinds that render a chart/list and want extra vertical room (2 grid rows).
25
+ * Stat/progress stay a single compact row so KPIs read like KPIs, not slabs. */
26
+ const TALL_KINDS = new Set(['bar', 'line', 'area', 'pie', 'donut', 'list', 'custom']);
27
+ /** Default footprint when a spec omits `size`: charts go half-width, stats a
28
+ * quarter — so a tablero of mixed widgets packs densely without manual sizing. */
29
+ export function defaultSize(spec) {
30
+ if (spec.size)
31
+ return spec.size;
32
+ return TALL_KINDS.has(spec.kind) ? 'md' : 'sm';
33
+ }
34
+ /** Combined col + row span for a widget's grid cell. Row-span drives the
35
+ * height contrast (chart=2, stat=1) that makes the layout feel designed. */
36
+ export function spanClass(spec) {
37
+ const col = SIZE_CLASS[defaultSize(spec)];
38
+ return `${col} ${TALL_KINDS.has(spec.kind) ? 'row-span-2' : 'row-span-1'}`;
39
+ }
40
+ /** True for chart/list widgets that occupy 2 rows. Used to order compact KPIs
41
+ * before charts in the unified grid so the top reads as a metric band. */
42
+ export function isTallWidget(spec) {
43
+ return TALL_KINDS.has(spec.kind);
44
+ }
23
45
  const RENDERERS = {
24
46
  stat: StatWidget,
25
47
  bar: BarWidget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.17.0",
3
+ "version": "18.17.2",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^6.0.0",
65
65
  "vitest": "^4.0.0",
66
66
  "zustand": "^5.0.0",
67
- "@asteby/metacore-sdk": "3.2.0",
68
- "@asteby/metacore-ui": "2.5.2"
67
+ "@asteby/metacore-ui": "2.5.2",
68
+ "@asteby/metacore-sdk": "3.2.0"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsc -p tsconfig.json",
@@ -15,7 +15,7 @@ import type {
15
15
  DashboardWidgetGroup,
16
16
  DashboardWidgetSpec,
17
17
  } from './dashboard-types'
18
- import { WidgetRenderer, WidgetSkeleton, SIZE_CLASS } from './widgets/widget-renderer'
18
+ import { WidgetRenderer, WidgetSkeleton, isTallWidget } from './widgets/widget-renderer'
19
19
 
20
20
  const DEFAULT_STRINGS: DashboardGridStrings = {
21
21
  emptyTitle: 'Your dashboard is empty',
@@ -155,52 +155,58 @@ export function DashboardGrid({
155
155
  )
156
156
  }
157
157
 
158
+ // ONE unified dense grid across every group. Per-group sections used to
159
+ // break the layout into rows, so a lone-widget group (e.g. a single KPI)
160
+ // left the rest of its row blank. Flattening + `grid-flow-row-dense`
161
+ // backfills those holes; ordering compact KPIs before charts makes the top
162
+ // read as a metric band and the charts mosaic below it. No blank space.
163
+ const ordered = React.useMemo(() => {
164
+ const flat = visibleGroups.flatMap((g) => g.widgets)
165
+ return flat
166
+ .map((w, i) => ({ w, i }))
167
+ .sort(
168
+ (a, b) =>
169
+ (isTallWidget(a.w) ? 1 : 0) - (isTallWidget(b.w) ? 1 : 0) ||
170
+ a.i - b.i,
171
+ )
172
+ .map((x) => x.w)
173
+ }, [visibleGroups])
174
+
158
175
  return (
159
- <div data-testid="dashboard-grid" className={cn('flex flex-col gap-8', className)}>
160
- {visibleGroups.map((group) => (
161
- <section key={group.title || '__default'} className="flex flex-col gap-3">
162
- {group.title && (
163
- <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
164
- {tr(group.title, group.title)}
165
- </h2>
166
- )}
167
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
168
- {group.widgets.map((spec) => {
169
- const size = spec.size ?? 'sm'
170
- return (
171
- <div key={spec.key} className={SIZE_CLASS[size]}>
172
- {loading ? (
173
- <WidgetSkeleton
174
- spec={{
175
- ...spec,
176
- title: tr(spec.title, spec.title),
177
- subtitle: tr(spec.subtitle, spec.subtitle),
178
- }}
179
- />
180
- ) : (
181
- <WidgetRenderer
182
- spec={{
183
- ...spec,
184
- title: tr(spec.title, spec.title),
185
- subtitle: tr(spec.subtitle, spec.subtitle),
186
- }}
187
- data={data?.[spec.key]}
188
- locale={locale}
189
- currency={currency}
190
- emptyText={
191
- spec.empty
192
- ? tr(spec.empty, spec.empty)
193
- : s.widgetEmpty
194
- }
195
- errorText={s.widgetError}
196
- />
197
- )}
198
- </div>
199
- )
200
- })}
176
+ <div
177
+ data-testid="dashboard-grid"
178
+ className={cn(
179
+ // Masonry: balanced CSS columns. Cards take their natural height
180
+ // (compact stats, taller charts) and flow to equalize column
181
+ // height, so there are NO blank cells — the gaps a fixed grid
182
+ // leaves around mixed 1×1 / 2×2 tiles simply don't exist here.
183
+ 'columns-1 gap-4 md:columns-2 xl:columns-3',
184
+ className,
185
+ )}
186
+ >
187
+ {ordered.map((spec) => {
188
+ const rspec = {
189
+ ...spec,
190
+ title: tr(spec.title, spec.title),
191
+ subtitle: tr(spec.subtitle, spec.subtitle),
192
+ }
193
+ return (
194
+ <div key={spec.key} className="mb-4 break-inside-avoid">
195
+ {loading ? (
196
+ <WidgetSkeleton spec={rspec} />
197
+ ) : (
198
+ <WidgetRenderer
199
+ spec={rspec}
200
+ data={data?.[spec.key]}
201
+ locale={locale}
202
+ currency={currency}
203
+ emptyText={spec.empty ? tr(spec.empty, spec.empty) : s.widgetEmpty}
204
+ errorText={s.widgetError}
205
+ />
206
+ )}
201
207
  </div>
202
- </section>
203
- ))}
208
+ )
209
+ })}
204
210
  </div>
205
211
  )
206
212
  }
@@ -53,7 +53,20 @@ const fmtCtx = (
53
53
  const hasSeries = (d?: WidgetData): d is WidgetData & { series: NonNullable<WidgetData['series']> } =>
54
54
  Array.isArray(d?.series) && d!.series!.length > 0
55
55
 
56
- const CHART_HEIGHT = 132
56
+ // Fixed-height chart body. In the masonry (CSS columns) layout cards take their
57
+ // natural height, so charts need a definite height for ResponsiveContainer to
58
+ // resolve. This height + the card chrome gives every chart card a consistent,
59
+ // taller footprint that balances cleanly against compact stat cards.
60
+ const CHART_H = 'h-[190px]'
61
+ function ChartArea({ children }: { children: React.ReactElement }) {
62
+ return (
63
+ <div className={cn(CHART_H, 'w-full')}>
64
+ <ResponsiveContainer width="100%" height="100%">
65
+ {children}
66
+ </ResponsiveContainer>
67
+ </div>
68
+ )
69
+ }
57
70
 
58
71
  // Compact recharts tooltip styled with theme tokens.
59
72
  function ChartTooltip({ ctx }: { ctx: WidgetFormatCtx }) {
@@ -94,11 +107,15 @@ export function StatWidget(p: WidgetRenderProps) {
94
107
  }
95
108
  >
96
109
  {hasValue ? (
97
- <div className="text-3xl font-semibold tabular-nums tracking-tight text-foreground">
98
- {formatWidgetValue(value!, ctx)}
110
+ <div className="flex min-h-[2.5rem] items-end">
111
+ <div className="text-[2rem] font-semibold leading-none tabular-nums tracking-tight text-foreground">
112
+ {formatWidgetValue(value!, ctx)}
113
+ </div>
99
114
  </div>
100
115
  ) : (
101
- <WidgetEmpty message={p.emptyText} />
116
+ <div className="flex min-h-[2.5rem] items-center text-sm text-muted-foreground">
117
+ {p.emptyText}
118
+ </div>
102
119
  )}
103
120
  </WidgetCard>
104
121
  )
@@ -117,7 +134,7 @@ export function BarWidget(p: WidgetRenderProps) {
117
134
  accent={p.spec.accent}
118
135
  >
119
136
  {hasSeries(p.data) ? (
120
- <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
137
+ <ChartArea>
121
138
  <BarChart data={p.data.series} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
122
139
  <CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
123
140
  <XAxis
@@ -135,9 +152,9 @@ export function BarWidget(p: WidgetRenderProps) {
135
152
  tickFormatter={(v: number) => formatAxisTick(v, ctx)}
136
153
  />
137
154
  <ChartTooltip ctx={ctx} />
138
- <Bar dataKey="value" fill={a.chartVar} radius={[4, 4, 0, 0]} maxBarSize={40} />
155
+ <Bar dataKey="value" fill={a.chartVar} radius={[4, 4, 0, 0]} maxBarSize={48} />
139
156
  </BarChart>
140
- </ResponsiveContainer>
157
+ </ChartArea>
141
158
  ) : (
142
159
  <WidgetEmpty message={p.emptyText} />
143
160
  )}
@@ -159,7 +176,7 @@ function TimeSeriesWidget(p: WidgetRenderProps & { variant: 'line' | 'area' }) {
159
176
  accent={p.spec.accent}
160
177
  >
161
178
  {hasSeries(p.data) ? (
162
- <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
179
+ <ChartArea>
163
180
  {p.variant === 'area' ? (
164
181
  <AreaChart data={p.data.series} margin={{ top: 4, right: 6, left: -16, bottom: 0 }}>
165
182
  <defs>
@@ -183,7 +200,7 @@ function TimeSeriesWidget(p: WidgetRenderProps & { variant: 'line' | 'area' }) {
183
200
  <Line type="monotone" dataKey="value" stroke={a.chartVar} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
184
201
  </LineChart>
185
202
  )}
186
- </ResponsiveContainer>
203
+ </ChartArea>
187
204
  ) : (
188
205
  <WidgetEmpty message={p.emptyText} />
189
206
  )}
@@ -210,26 +227,28 @@ function CircularWidget(p: WidgetRenderProps & { variant: 'pie' | 'donut' }) {
210
227
  accent={p.spec.accent}
211
228
  >
212
229
  {hasSeries(p.data) ? (
213
- <div className="flex items-center gap-3">
214
- <ResponsiveContainer width="50%" height={CHART_HEIGHT}>
215
- <PieChart>
216
- <ChartTooltip ctx={ctx} />
217
- <Pie
218
- data={p.data.series}
219
- dataKey="value"
220
- nameKey="label"
221
- innerRadius={p.variant === 'donut' ? 32 : 0}
222
- outerRadius={56}
223
- paddingAngle={p.variant === 'donut' ? 2 : 0}
224
- stroke="var(--background, #fff)"
225
- strokeWidth={2}
226
- >
227
- {p.data.series.map((_, i) => (
228
- <Cell key={i} fill={paletteColor(i)} />
229
- ))}
230
- </Pie>
231
- </PieChart>
232
- </ResponsiveContainer>
230
+ <div className={cn(CHART_H, 'flex items-center gap-3')}>
231
+ <div className="h-full min-h-0 w-[46%] shrink-0">
232
+ <ResponsiveContainer width="100%" height="100%">
233
+ <PieChart>
234
+ <ChartTooltip ctx={ctx} />
235
+ <Pie
236
+ data={p.data.series}
237
+ dataKey="value"
238
+ nameKey="label"
239
+ innerRadius={p.variant === 'donut' ? '55%' : 0}
240
+ outerRadius="92%"
241
+ paddingAngle={p.variant === 'donut' ? 2 : 0}
242
+ stroke="var(--background, #fff)"
243
+ strokeWidth={2}
244
+ >
245
+ {p.data.series.map((_, i) => (
246
+ <Cell key={i} fill={paletteColor(i)} />
247
+ ))}
248
+ </Pie>
249
+ </PieChart>
250
+ </ResponsiveContainer>
251
+ </div>
233
252
  <ul className="flex min-w-0 flex-1 flex-col gap-1.5">
234
253
  {p.data.series.slice(0, 6).map((pt, i) => (
235
254
  <li key={pt.key} className="flex items-center gap-2 text-xs">
@@ -78,7 +78,7 @@ export function WidgetCard({
78
78
  </div>
79
79
  {headerExtra && <div className="shrink-0">{headerExtra}</div>}
80
80
  </CardHeader>
81
- <CardContent className="flex flex-1 flex-col justify-end pt-1">
81
+ <CardContent className="flex min-h-0 flex-1 flex-col pt-1">
82
82
  {children}
83
83
  </CardContent>
84
84
  </Card>
@@ -26,12 +26,37 @@ export const SIZE_SPAN: Record<WidgetSize, number> = {
26
26
  full: 4,
27
27
  }
28
28
 
29
- /** Tailwind col-span class per size (static → scanned in this package build). */
29
+ /** Tailwind col-span class per size (static → scanned in this package build).
30
+ * Grid is 2 cols on mobile, 4 on lg: sm=quarter, md=half, lg=¾, full=row. */
30
31
  export const SIZE_CLASS: Record<WidgetSize, string> = {
31
- sm: 'sm:col-span-2 lg:col-span-1',
32
- md: 'sm:col-span-2 lg:col-span-2',
33
- lg: 'sm:col-span-2 lg:col-span-3',
34
- full: 'sm:col-span-2 lg:col-span-4',
32
+ sm: 'col-span-1 lg:col-span-1',
33
+ md: 'col-span-2 lg:col-span-2',
34
+ lg: 'col-span-2 lg:col-span-3',
35
+ full: 'col-span-2 lg:col-span-4',
36
+ }
37
+
38
+ /** Kinds that render a chart/list and want extra vertical room (2 grid rows).
39
+ * Stat/progress stay a single compact row so KPIs read like KPIs, not slabs. */
40
+ const TALL_KINDS = new Set(['bar', 'line', 'area', 'pie', 'donut', 'list', 'custom'])
41
+
42
+ /** Default footprint when a spec omits `size`: charts go half-width, stats a
43
+ * quarter — so a tablero of mixed widgets packs densely without manual sizing. */
44
+ export function defaultSize(spec: DashboardWidgetSpec): WidgetSize {
45
+ if (spec.size) return spec.size
46
+ return TALL_KINDS.has(spec.kind) ? 'md' : 'sm'
47
+ }
48
+
49
+ /** Combined col + row span for a widget's grid cell. Row-span drives the
50
+ * height contrast (chart=2, stat=1) that makes the layout feel designed. */
51
+ export function spanClass(spec: DashboardWidgetSpec): string {
52
+ const col = SIZE_CLASS[defaultSize(spec)]
53
+ return `${col} ${TALL_KINDS.has(spec.kind) ? 'row-span-2' : 'row-span-1'}`
54
+ }
55
+
56
+ /** True for chart/list widgets that occupy 2 rows. Used to order compact KPIs
57
+ * before charts in the unified grid so the top reads as a metric band. */
58
+ export function isTallWidget(spec: DashboardWidgetSpec): boolean {
59
+ return TALL_KINDS.has(spec.kind)
35
60
  }
36
61
 
37
62
  const RENDERERS: Record<