@asteby/metacore-runtime-react 18.16.1 → 18.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ // Built-in dashboard widget renderers, one per declarative `kind`. Each takes
2
+ // the resolved spec + computed WidgetData + the format context (locale/currency)
3
+ // and renders the body INSIDE a <WidgetCard>. recharts powers the charts; colors
4
+ // come from theme CSS vars (dark-mode safe), curves are smooth, axes/legends
5
+ // compact, tooltips on.
6
+
7
+ import * as React from 'react'
8
+ import {
9
+ Area,
10
+ AreaChart,
11
+ Bar,
12
+ BarChart,
13
+ CartesianGrid,
14
+ Cell,
15
+ Line,
16
+ LineChart,
17
+ Pie,
18
+ PieChart,
19
+ ResponsiveContainer,
20
+ Tooltip,
21
+ XAxis,
22
+ YAxis,
23
+ } from 'recharts'
24
+ import { cn } from '@asteby/metacore-ui/lib'
25
+ import type { DashboardWidgetSpec, WidgetData } from '../dashboard-types'
26
+ import { WidgetCard, DeltaChip, WidgetEmpty } from './widget-card'
27
+ import {
28
+ accentClasses,
29
+ paletteColor,
30
+ formatWidgetValue,
31
+ formatAxisTick,
32
+ formatDelta,
33
+ CHART_GRID,
34
+ CHART_MUTED,
35
+ type WidgetFormatCtx,
36
+ } from './widget-format'
37
+
38
+ export interface WidgetRenderProps {
39
+ spec: DashboardWidgetSpec
40
+ data?: WidgetData
41
+ locale?: string
42
+ currency?: string
43
+ /** i18n: per-widget empty fallback (already translated). */
44
+ emptyText: string
45
+ }
46
+
47
+ const fmtCtx = (
48
+ spec: DashboardWidgetSpec,
49
+ locale?: string,
50
+ currency?: string,
51
+ ): WidgetFormatCtx => ({ format: spec.format, locale, currency })
52
+
53
+ const hasSeries = (d?: WidgetData): d is WidgetData & { series: NonNullable<WidgetData['series']> } =>
54
+ Array.isArray(d?.series) && d!.series!.length > 0
55
+
56
+ // Chart body that fills the card's flexible height. The dashboard grid gives
57
+ // each chart cell a definite height (fixed auto-rows × row-span), so height:100%
58
+ // resolves cleanly and charts scale with the card instead of a fixed stub.
59
+ function ChartArea({ children }: { children: React.ReactElement }) {
60
+ return (
61
+ <div className="min-h-0 flex-1">
62
+ <ResponsiveContainer width="100%" height="100%">
63
+ {children}
64
+ </ResponsiveContainer>
65
+ </div>
66
+ )
67
+ }
68
+
69
+ // Compact recharts tooltip styled with theme tokens.
70
+ function ChartTooltip({ ctx }: { ctx: WidgetFormatCtx }) {
71
+ return (
72
+ <Tooltip
73
+ cursor={{ fill: 'var(--muted, rgba(148,163,184,0.12))', opacity: 0.4 }}
74
+ contentStyle={{
75
+ background: 'var(--popover, #fff)',
76
+ border: '1px solid var(--border, #e2e8f0)',
77
+ borderRadius: 8,
78
+ fontSize: 12,
79
+ color: 'var(--popover-foreground, #0f172a)',
80
+ boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
81
+ }}
82
+ labelStyle={{ color: 'var(--muted-foreground, #64748b)', marginBottom: 2 }}
83
+ formatter={(v: number) => formatWidgetValue(Number(v), ctx)}
84
+ />
85
+ )
86
+ }
87
+
88
+ // --- stat -----------------------------------------------------------------
89
+ export function StatWidget(p: WidgetRenderProps) {
90
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
91
+ const value = p.data?.value
92
+ const delta = p.data?.delta
93
+ const hasValue = typeof value === 'number' && !Number.isNaN(value)
94
+ return (
95
+ <WidgetCard
96
+ data-testid={`widget-${p.spec.key}`}
97
+ title={p.spec.title}
98
+ subtitle={p.spec.subtitle}
99
+ icon={p.spec.icon}
100
+ accent={p.spec.accent}
101
+ headerExtra={
102
+ typeof delta === 'number' ? (
103
+ <DeltaChip delta={delta} text={formatDelta(delta, p.locale)} />
104
+ ) : undefined
105
+ }
106
+ >
107
+ {hasValue ? (
108
+ <div className="flex min-h-0 flex-1 flex-col justify-center">
109
+ <div className="text-[2rem] font-semibold leading-none tabular-nums tracking-tight text-foreground">
110
+ {formatWidgetValue(value!, ctx)}
111
+ </div>
112
+ </div>
113
+ ) : (
114
+ <WidgetEmpty message={p.emptyText} />
115
+ )}
116
+ </WidgetCard>
117
+ )
118
+ }
119
+
120
+ // --- bar ------------------------------------------------------------------
121
+ export function BarWidget(p: WidgetRenderProps) {
122
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
123
+ const a = accentClasses(p.spec.accent)
124
+ return (
125
+ <WidgetCard
126
+ data-testid={`widget-${p.spec.key}`}
127
+ title={p.spec.title}
128
+ subtitle={p.spec.subtitle}
129
+ icon={p.spec.icon}
130
+ accent={p.spec.accent}
131
+ >
132
+ {hasSeries(p.data) ? (
133
+ <ChartArea>
134
+ <BarChart data={p.data.series} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
135
+ <CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
136
+ <XAxis
137
+ dataKey="label"
138
+ tick={{ fontSize: 10, fill: CHART_MUTED }}
139
+ tickLine={false}
140
+ axisLine={false}
141
+ interval="preserveStartEnd"
142
+ />
143
+ <YAxis
144
+ tick={{ fontSize: 10, fill: CHART_MUTED }}
145
+ tickLine={false}
146
+ axisLine={false}
147
+ width={44}
148
+ tickFormatter={(v: number) => formatAxisTick(v, ctx)}
149
+ />
150
+ <ChartTooltip ctx={ctx} />
151
+ <Bar dataKey="value" fill={a.chartVar} radius={[4, 4, 0, 0]} maxBarSize={48} />
152
+ </BarChart>
153
+ </ChartArea>
154
+ ) : (
155
+ <WidgetEmpty message={p.emptyText} />
156
+ )}
157
+ </WidgetCard>
158
+ )
159
+ }
160
+
161
+ // --- line / area (shared) -------------------------------------------------
162
+ function TimeSeriesWidget(p: WidgetRenderProps & { variant: 'line' | 'area' }) {
163
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
164
+ const a = accentClasses(p.spec.accent)
165
+ const gradId = `wg-grad-${p.spec.key}`
166
+ return (
167
+ <WidgetCard
168
+ data-testid={`widget-${p.spec.key}`}
169
+ title={p.spec.title}
170
+ subtitle={p.spec.subtitle}
171
+ icon={p.spec.icon}
172
+ accent={p.spec.accent}
173
+ >
174
+ {hasSeries(p.data) ? (
175
+ <ChartArea>
176
+ {p.variant === 'area' ? (
177
+ <AreaChart data={p.data.series} margin={{ top: 4, right: 6, left: -16, bottom: 0 }}>
178
+ <defs>
179
+ <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
180
+ <stop offset="0%" stopColor={a.chartVar} stopOpacity={0.35} />
181
+ <stop offset="100%" stopColor={a.chartVar} stopOpacity={0.02} />
182
+ </linearGradient>
183
+ </defs>
184
+ <CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
185
+ <XAxis dataKey="label" tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
186
+ <YAxis tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} width={44} tickFormatter={(v: number) => formatAxisTick(v, ctx)} />
187
+ <ChartTooltip ctx={ctx} />
188
+ <Area type="monotone" dataKey="value" stroke={a.chartVar} strokeWidth={2} fill={`url(#${gradId})`} />
189
+ </AreaChart>
190
+ ) : (
191
+ <LineChart data={p.data.series} margin={{ top: 4, right: 6, left: -16, bottom: 0 }}>
192
+ <CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
193
+ <XAxis dataKey="label" tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
194
+ <YAxis tick={{ fontSize: 10, fill: CHART_MUTED }} tickLine={false} axisLine={false} width={44} tickFormatter={(v: number) => formatAxisTick(v, ctx)} />
195
+ <ChartTooltip ctx={ctx} />
196
+ <Line type="monotone" dataKey="value" stroke={a.chartVar} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
197
+ </LineChart>
198
+ )}
199
+ </ChartArea>
200
+ ) : (
201
+ <WidgetEmpty message={p.emptyText} />
202
+ )}
203
+ </WidgetCard>
204
+ )
205
+ }
206
+
207
+ export function LineWidget(p: WidgetRenderProps) {
208
+ return <TimeSeriesWidget {...p} variant="line" />
209
+ }
210
+ export function AreaWidget(p: WidgetRenderProps) {
211
+ return <TimeSeriesWidget {...p} variant="area" />
212
+ }
213
+
214
+ // --- pie / donut (shared) -------------------------------------------------
215
+ function CircularWidget(p: WidgetRenderProps & { variant: 'pie' | 'donut' }) {
216
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
217
+ return (
218
+ <WidgetCard
219
+ data-testid={`widget-${p.spec.key}`}
220
+ title={p.spec.title}
221
+ subtitle={p.spec.subtitle}
222
+ icon={p.spec.icon}
223
+ accent={p.spec.accent}
224
+ >
225
+ {hasSeries(p.data) ? (
226
+ <div className="flex min-h-0 flex-1 items-center gap-3">
227
+ <div className="h-full min-h-0 w-[46%] shrink-0">
228
+ <ResponsiveContainer width="100%" height="100%">
229
+ <PieChart>
230
+ <ChartTooltip ctx={ctx} />
231
+ <Pie
232
+ data={p.data.series}
233
+ dataKey="value"
234
+ nameKey="label"
235
+ innerRadius={p.variant === 'donut' ? '55%' : 0}
236
+ outerRadius="92%"
237
+ paddingAngle={p.variant === 'donut' ? 2 : 0}
238
+ stroke="var(--background, #fff)"
239
+ strokeWidth={2}
240
+ >
241
+ {p.data.series.map((_, i) => (
242
+ <Cell key={i} fill={paletteColor(i)} />
243
+ ))}
244
+ </Pie>
245
+ </PieChart>
246
+ </ResponsiveContainer>
247
+ </div>
248
+ <ul className="flex min-w-0 flex-1 flex-col gap-1.5">
249
+ {p.data.series.slice(0, 6).map((pt, i) => (
250
+ <li key={pt.key} className="flex items-center gap-2 text-xs">
251
+ <span className="size-2.5 shrink-0 rounded-sm" style={{ background: paletteColor(i) }} />
252
+ <span className="truncate text-muted-foreground">{pt.label}</span>
253
+ <span className="ml-auto shrink-0 font-medium tabular-nums text-foreground">
254
+ {formatWidgetValue(pt.value, ctx)}
255
+ </span>
256
+ </li>
257
+ ))}
258
+ </ul>
259
+ </div>
260
+ ) : (
261
+ <WidgetEmpty message={p.emptyText} />
262
+ )}
263
+ </WidgetCard>
264
+ )
265
+ }
266
+
267
+ export function PieWidget(p: WidgetRenderProps) {
268
+ return <CircularWidget {...p} variant="pie" />
269
+ }
270
+ export function DonutWidget(p: WidgetRenderProps) {
271
+ return <CircularWidget {...p} variant="donut" />
272
+ }
273
+
274
+ // --- list (top-N with proportion bars) ------------------------------------
275
+ export function ListWidget(p: WidgetRenderProps) {
276
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
277
+ const a = accentClasses(p.spec.accent)
278
+ const series = p.data?.series ?? []
279
+ const max = series.reduce((m, s) => Math.max(m, s.value), 0) || 1
280
+ return (
281
+ <WidgetCard
282
+ data-testid={`widget-${p.spec.key}`}
283
+ title={p.spec.title}
284
+ subtitle={p.spec.subtitle}
285
+ icon={p.spec.icon}
286
+ accent={p.spec.accent}
287
+ >
288
+ {series.length > 0 ? (
289
+ <ul className="flex flex-col gap-2.5">
290
+ {series.map((pt) => (
291
+ <li key={pt.key} className="flex flex-col gap-1">
292
+ <div className="flex items-baseline justify-between gap-2 text-xs">
293
+ <span className="truncate text-foreground">{pt.label}</span>
294
+ <span className="shrink-0 font-medium tabular-nums text-muted-foreground">
295
+ {formatWidgetValue(pt.value, ctx)}
296
+ </span>
297
+ </div>
298
+ <div className={cn('h-1.5 w-full overflow-hidden rounded-full', a.track)}>
299
+ <div
300
+ className={cn('h-full rounded-full transition-all', a.bar)}
301
+ style={{ width: `${Math.max(2, (pt.value / max) * 100)}%` }}
302
+ />
303
+ </div>
304
+ </li>
305
+ ))}
306
+ </ul>
307
+ ) : (
308
+ <WidgetEmpty message={p.emptyText} />
309
+ )}
310
+ </WidgetCard>
311
+ )
312
+ }
313
+
314
+ // --- progress -------------------------------------------------------------
315
+ // A scalar rendered as a proportion bar. When `format:'percent'` the value is
316
+ // a fraction in [0,1]; otherwise it's shown as the big number with a full bar.
317
+ export function ProgressWidget(p: WidgetRenderProps) {
318
+ const ctx = fmtCtx(p.spec, p.locale, p.currency)
319
+ const a = accentClasses(p.spec.accent)
320
+ const value = p.data?.value
321
+ const hasValue = typeof value === 'number' && !Number.isNaN(value)
322
+ const pct =
323
+ p.spec.format === 'percent'
324
+ ? Math.min(100, Math.max(0, (value ?? 0) * 100))
325
+ : 100
326
+ return (
327
+ <WidgetCard
328
+ data-testid={`widget-${p.spec.key}`}
329
+ title={p.spec.title}
330
+ subtitle={p.spec.subtitle}
331
+ icon={p.spec.icon}
332
+ accent={p.spec.accent}
333
+ >
334
+ {hasValue ? (
335
+ <div className="flex flex-col gap-2">
336
+ <div className="text-2xl font-semibold tabular-nums tracking-tight text-foreground">
337
+ {formatWidgetValue(value!, ctx)}
338
+ </div>
339
+ <div className={cn('h-2 w-full overflow-hidden rounded-full', a.track)}>
340
+ <div
341
+ className={cn('h-full rounded-full transition-all duration-500', a.bar)}
342
+ style={{ width: `${pct}%` }}
343
+ />
344
+ </div>
345
+ </div>
346
+ ) : (
347
+ <WidgetEmpty message={p.emptyText} />
348
+ )}
349
+ </WidgetCard>
350
+ )
351
+ }
@@ -0,0 +1,125 @@
1
+ // Shared "pro" card chrome for every dashboard widget. Subtle border, hover
2
+ // ring, an accent icon chip, title + optional subtitle, and a subtle mount
3
+ // motion done in pure CSS (runtime-react does not ship framer-motion). The
4
+ // chrome is identical for declarative renderers AND `kind:"custom"` federated
5
+ // widgets so they visually combine in the same grid.
6
+
7
+ import * as React from 'react'
8
+ import { Card, CardContent, CardHeader } from '@asteby/metacore-ui'
9
+ import { cn } from '@asteby/metacore-ui/lib'
10
+ import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
11
+ import { accentClasses } from './widget-format'
12
+ import type { WidgetAccent } from '../dashboard-types'
13
+
14
+ export interface WidgetCardProps {
15
+ title: string
16
+ subtitle?: string
17
+ icon?: string
18
+ accent?: WidgetAccent
19
+ /** Right-aligned header slot (e.g. a delta chip). */
20
+ headerExtra?: React.ReactNode
21
+ /** Body content. */
22
+ children?: React.ReactNode
23
+ className?: string
24
+ /** Forwarded for testing/automation. */
25
+ 'data-testid'?: string
26
+ }
27
+
28
+ /**
29
+ * The card frame shared by all widgets. Keep the chrome here so a single style
30
+ * change propagates to every kind (and to federated custom widgets).
31
+ */
32
+ export function WidgetCard({
33
+ title,
34
+ subtitle,
35
+ icon,
36
+ accent,
37
+ headerExtra,
38
+ children,
39
+ className,
40
+ ...rest
41
+ }: WidgetCardProps) {
42
+ const a = accentClasses(accent)
43
+ const showIcon = icon && isLucideIconName(icon)
44
+ return (
45
+ <Card
46
+ {...rest}
47
+ className={cn(
48
+ // base: subtle border, ring on hover, gentle lift, mount fade-in
49
+ 'group/widget relative flex h-full flex-col overflow-hidden',
50
+ 'border-border/60 transition-all duration-200',
51
+ 'hover:border-border hover:ring-1 hover:ring-ring/30 hover:shadow-sm',
52
+ 'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-500',
53
+ className,
54
+ )}
55
+ >
56
+ <CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0 pb-2">
57
+ <div className="flex min-w-0 items-start gap-3">
58
+ {showIcon && (
59
+ <span
60
+ className={cn(
61
+ 'flex size-9 shrink-0 items-center justify-center rounded-lg',
62
+ a.chip,
63
+ )}
64
+ >
65
+ <DynamicIcon name={icon!} className="size-[18px]" />
66
+ </span>
67
+ )}
68
+ <div className="min-w-0">
69
+ <div className="truncate text-sm font-medium leading-tight text-foreground">
70
+ {title}
71
+ </div>
72
+ {subtitle && (
73
+ <div className="mt-0.5 truncate text-xs text-muted-foreground">
74
+ {subtitle}
75
+ </div>
76
+ )}
77
+ </div>
78
+ </div>
79
+ {headerExtra && <div className="shrink-0">{headerExtra}</div>}
80
+ </CardHeader>
81
+ <CardContent className="flex min-h-0 flex-1 flex-col pt-1">
82
+ {children}
83
+ </CardContent>
84
+ </Card>
85
+ )
86
+ }
87
+
88
+ /** Delta chip: green up / red down, neutral on zero. `text` is preformatted. */
89
+ export function DeltaChip({ delta, text }: { delta: number; text: string }) {
90
+ const up = delta > 0
91
+ const down = delta < 0
92
+ return (
93
+ <span
94
+ className={cn(
95
+ 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums',
96
+ up && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
97
+ down && 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
98
+ !up && !down && 'bg-muted text-muted-foreground',
99
+ )}
100
+ >
101
+ {up && '▲'}
102
+ {down && '▼'}
103
+ {text}
104
+ </span>
105
+ )
106
+ }
107
+
108
+ /** Centered empty state inside a widget body (no data / missing). */
109
+ export function WidgetEmpty({ message }: { message: string }) {
110
+ return (
111
+ <div className="flex flex-1 items-center justify-center py-6 text-center text-xs text-muted-foreground">
112
+ {message}
113
+ </div>
114
+ )
115
+ }
116
+
117
+ /** Per-widget error state — isolated so a broken widget never tumbles the grid. */
118
+ export function WidgetError({ message }: { message: string }) {
119
+ return (
120
+ <div className="flex flex-1 items-center justify-center gap-2 py-6 text-center text-xs text-destructive">
121
+ <DynamicIcon name="TriangleAlert" className="size-4" />
122
+ <span>{message}</span>
123
+ </div>
124
+ )
125
+ }
@@ -0,0 +1,181 @@
1
+ // Shared widget formatting + accent theming. Reuses the same Intl-based
2
+ // approach as the table cells (dynamic-columns) so dashboard numbers read
3
+ // identically to the grids: org currency + locale, compact notation, percent.
4
+
5
+ import type { WidgetAccent, WidgetFormat } from '../dashboard-types'
6
+
7
+ export interface WidgetFormatCtx {
8
+ format?: WidgetFormat
9
+ locale?: string
10
+ currency?: string
11
+ }
12
+
13
+ /**
14
+ * Formats a scalar/series value with the widget's declared format. `currency`
15
+ * uses the org currency + locale (same fallback chain as table cells); `percent`
16
+ * treats the value as a fraction (0.142 → "14.2%"); `compact` uses Intl compact
17
+ * notation (1_200 → "1.2K").
18
+ */
19
+ export function formatWidgetValue(
20
+ value: number,
21
+ { format = 'number', locale, currency }: WidgetFormatCtx,
22
+ ): string {
23
+ if (value === null || value === undefined || Number.isNaN(value)) return '—'
24
+ const loc = locale || undefined
25
+ switch (format) {
26
+ case 'currency':
27
+ return new Intl.NumberFormat(loc, {
28
+ style: 'currency',
29
+ currency: currency || 'USD',
30
+ maximumFractionDigits: 2,
31
+ }).format(value)
32
+ case 'percent':
33
+ return new Intl.NumberFormat(loc, {
34
+ style: 'percent',
35
+ minimumFractionDigits: 0,
36
+ maximumFractionDigits: 1,
37
+ }).format(value)
38
+ case 'compact':
39
+ return new Intl.NumberFormat(loc, {
40
+ notation: 'compact',
41
+ maximumFractionDigits: 1,
42
+ }).format(value)
43
+ case 'number':
44
+ default:
45
+ return new Intl.NumberFormat(loc, {
46
+ maximumFractionDigits: 2,
47
+ }).format(value)
48
+ }
49
+ }
50
+
51
+ /** Axis-tick formatter — always compact so charts stay legible. */
52
+ export function formatAxisTick(
53
+ value: number,
54
+ { format, locale, currency }: WidgetFormatCtx,
55
+ ): string {
56
+ const loc = locale || undefined
57
+ if (format === 'currency') {
58
+ return new Intl.NumberFormat(loc, {
59
+ style: 'currency',
60
+ currency: currency || 'USD',
61
+ notation: 'compact',
62
+ maximumFractionDigits: 1,
63
+ }).format(value)
64
+ }
65
+ if (format === 'percent') {
66
+ return new Intl.NumberFormat(loc, {
67
+ style: 'percent',
68
+ maximumFractionDigits: 0,
69
+ }).format(value)
70
+ }
71
+ return new Intl.NumberFormat(loc, {
72
+ notation: 'compact',
73
+ maximumFractionDigits: 1,
74
+ }).format(value)
75
+ }
76
+
77
+ /** Formats the compare delta fraction (0.142 → "+14.2%"). */
78
+ export function formatDelta(delta: number, locale?: string): string {
79
+ const sign = delta > 0 ? '+' : ''
80
+ return (
81
+ sign +
82
+ new Intl.NumberFormat(locale || undefined, {
83
+ style: 'percent',
84
+ minimumFractionDigits: 0,
85
+ maximumFractionDigits: 1,
86
+ }).format(delta)
87
+ )
88
+ }
89
+
90
+ // --- Accent theming -------------------------------------------------------
91
+ // Tailwind tokens scanned in THIS package's build (the renderers live in the
92
+ // SDK, not the federated host), so static utility classes are safe. We avoid
93
+ // host-dependent arbitrary values. Each accent maps to a small bundle of
94
+ // classes for the icon chip, the chart stroke/fill and the progress/list bar.
95
+
96
+ export interface AccentClasses {
97
+ /** Icon chip background + foreground. */
98
+ chip: string
99
+ /** Text color for accented labels. */
100
+ text: string
101
+ /** Solid bar/fill background (progress, list proportion). */
102
+ bar: string
103
+ /** Soft track background behind a bar. */
104
+ track: string
105
+ /** Hex-ish CSS color used for recharts stroke/fill (var-based). */
106
+ chartVar: string
107
+ }
108
+
109
+ const ACCENTS: Record<WidgetAccent, AccentClasses> = {
110
+ emerald: {
111
+ chip: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
112
+ text: 'text-emerald-600 dark:text-emerald-400',
113
+ bar: 'bg-emerald-500',
114
+ track: 'bg-emerald-500/15',
115
+ chartVar: 'var(--color-widget-emerald, #10b981)',
116
+ },
117
+ sky: {
118
+ chip: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
119
+ text: 'text-sky-600 dark:text-sky-400',
120
+ bar: 'bg-sky-500',
121
+ track: 'bg-sky-500/15',
122
+ chartVar: 'var(--color-widget-sky, #0ea5e9)',
123
+ },
124
+ violet: {
125
+ chip: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
126
+ text: 'text-violet-600 dark:text-violet-400',
127
+ bar: 'bg-violet-500',
128
+ track: 'bg-violet-500/15',
129
+ chartVar: 'var(--color-widget-violet, #8b5cf6)',
130
+ },
131
+ amber: {
132
+ chip: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
133
+ text: 'text-amber-600 dark:text-amber-400',
134
+ bar: 'bg-amber-500',
135
+ track: 'bg-amber-500/15',
136
+ chartVar: 'var(--color-widget-amber, #f59e0b)',
137
+ },
138
+ rose: {
139
+ chip: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
140
+ text: 'text-rose-600 dark:text-rose-400',
141
+ bar: 'bg-rose-500',
142
+ track: 'bg-rose-500/15',
143
+ chartVar: 'var(--color-widget-rose, #f43f5e)',
144
+ },
145
+ slate: {
146
+ chip: 'bg-slate-500/10 text-slate-600 dark:text-slate-300',
147
+ text: 'text-slate-600 dark:text-slate-300',
148
+ bar: 'bg-slate-500',
149
+ track: 'bg-slate-500/15',
150
+ chartVar: 'var(--color-widget-slate, #64748b)',
151
+ },
152
+ }
153
+
154
+ const DEFAULT_ACCENT: WidgetAccent = 'sky'
155
+
156
+ /** Resolves the accent class bundle, defaulting to `sky`. */
157
+ export function accentClasses(accent?: WidgetAccent): AccentClasses {
158
+ return ACCENTS[accent ?? DEFAULT_ACCENT] ?? ACCENTS[DEFAULT_ACCENT]
159
+ }
160
+
161
+ /**
162
+ * A categorical palette for multi-series charts (pie/donut/bar by bucket).
163
+ * Cycles the accent chart vars so slices stay theme-aware + dark-mode safe.
164
+ */
165
+ export const CHART_PALETTE: string[] = [
166
+ ACCENTS.sky.chartVar,
167
+ ACCENTS.emerald.chartVar,
168
+ ACCENTS.violet.chartVar,
169
+ ACCENTS.amber.chartVar,
170
+ ACCENTS.rose.chartVar,
171
+ ACCENTS.slate.chartVar,
172
+ ]
173
+
174
+ /** Picks a palette color by index, wrapping around. */
175
+ export function paletteColor(index: number): string {
176
+ return CHART_PALETTE[index % CHART_PALETTE.length]
177
+ }
178
+
179
+ /** Grid/axis muted color from the theme (works in light + dark). */
180
+ export const CHART_MUTED = 'var(--muted-foreground, #94a3b8)'
181
+ export const CHART_GRID = 'var(--border, #e2e8f0)'