@startsimpli/ui 0.4.88 → 0.4.90

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.88",
3
+ "version": "0.4.90",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -43,7 +43,7 @@
43
43
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
44
44
  "react": "^18.0.0 || ^19.0.0",
45
45
  "react-dom": "^18.0.0 || ^19.0.0",
46
- "@startsimpli/auth": "^0.4.32",
46
+ "@startsimpli/auth": "^0.4.34",
47
47
  "@startsimpli/api": "^0.5.54"
48
48
  },
49
49
  "peerDependenciesMeta": {
@@ -0,0 +1,182 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface AreaTrendProps {
5
+ /** The series, oldest first. Empty / single-point / flat series all render. */
6
+ data: number[]
7
+ /** Any CSS colour. Defaults to `currentColor` so the caller tints via `text-*`. */
8
+ color?: string
9
+ /** Plot height in px (excludes the optional header row). */
10
+ height?: number
11
+ /** Optional header row, left side. */
12
+ label?: string
13
+ /** Optional header row, right side. */
14
+ value?: React.ReactNode
15
+ /** Draw faint horizontal baselines behind the plot. */
16
+ showBaseline?: boolean
17
+ /** How many baselines when `showBaseline` — clamped to 1..10. */
18
+ baselineCount?: number
19
+ /** Overrides the generated description. */
20
+ ariaLabel?: string
21
+ className?: string
22
+ }
23
+
24
+ /** Internal viewBox width; `preserveAspectRatio="none"` stretches it to the box. */
25
+ const VIEW_W = 100
26
+
27
+ /**
28
+ * Deterministic id from a seed so the gradient survives SSR without `useId`
29
+ * (which would force this into a client component). Two AreaTrends sharing a
30
+ * colour share a gradient def — same stops, so the collision is harmless.
31
+ */
32
+ function gradientIdFor(seed: string): string {
33
+ let hash = 0
34
+ for (let i = 0; i < seed.length; i += 1) {
35
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0
36
+ }
37
+ return `area-trend-${(hash >>> 0).toString(36)}`
38
+ }
39
+
40
+ interface Geometry {
41
+ line: string
42
+ area: string
43
+ }
44
+
45
+ /**
46
+ * Maps a series onto the viewBox. A flat series (max === min, including a
47
+ * single point and an all-zero series) rides the vertical centre rather than
48
+ * dividing by a zero range.
49
+ */
50
+ export function areaTrendGeometry(data: number[], height: number): Geometry | null {
51
+ const points = data.filter((n) => Number.isFinite(n))
52
+ if (points.length === 0) return null
53
+
54
+ const padding = 2
55
+ const usable = Math.max(height - padding * 2, 1)
56
+ const min = Math.min(...points)
57
+ const max = Math.max(...points)
58
+ const flat = max === min
59
+
60
+ const coords = points.map((v, i) => {
61
+ const x = points.length === 1 ? VIEW_W / 2 : (i / (points.length - 1)) * VIEW_W
62
+ const y = flat ? height / 2 : height - padding - ((v - min) / (max - min)) * usable
63
+ return { x, y }
64
+ })
65
+
66
+ // A single point has no segment to stroke — draw a flat rule across the box
67
+ // so the caller still sees the value's level instead of an empty frame.
68
+ const path =
69
+ coords.length === 1
70
+ ? `M 0,${coords[0].y} L ${VIEW_W},${coords[0].y}`
71
+ : `M ${coords.map((p) => `${p.x},${p.y}`).join(' L ')}`
72
+
73
+ return {
74
+ line: path,
75
+ area: `${path} L ${VIEW_W},${height} L 0,${height} Z`,
76
+ }
77
+ }
78
+
79
+ function describe(data: number[], label?: string): string {
80
+ const points = data.filter((n) => Number.isFinite(n))
81
+ const subject = label ? `${label} trend` : 'Trend'
82
+ if (points.length === 0) return `${subject}: no data`
83
+ if (points.length === 1) return `${subject}: single value ${points[0]}`
84
+
85
+ const first = points[0]
86
+ const last = points[points.length - 1]
87
+ const shape = last > first ? 'rising' : last < first ? 'falling' : 'flat'
88
+ return `${subject}: ${shape} over ${points.length} points, from ${first} to ${last}`
89
+ }
90
+
91
+ /**
92
+ * A gradient-filled area chart over a plain number series. Pure SVG — no
93
+ * charting dependency, no hooks, safe to render on the server.
94
+ */
95
+ export function AreaTrend({
96
+ data,
97
+ color = 'currentColor',
98
+ height = 96,
99
+ label,
100
+ value,
101
+ showBaseline = false,
102
+ baselineCount = 3,
103
+ ariaLabel,
104
+ className,
105
+ }: AreaTrendProps) {
106
+ const safeHeight = Math.max(height, 1)
107
+ const geometry = areaTrendGeometry(data, safeHeight)
108
+ const gradientId = gradientIdFor(`${color}|${safeHeight}`)
109
+ const lines = Math.min(Math.max(Math.round(baselineCount), 1), 10)
110
+ const hasHeader = label !== undefined || value !== undefined
111
+
112
+ return (
113
+ <div className={cn('w-full', className)}>
114
+ {hasHeader && (
115
+ <div className="mb-2 flex items-baseline justify-between gap-2">
116
+ {label !== undefined && (
117
+ <span className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
118
+ {label}
119
+ </span>
120
+ )}
121
+ {value !== undefined && (
122
+ <span className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
123
+ {value}
124
+ </span>
125
+ )}
126
+ </div>
127
+ )}
128
+ <svg
129
+ width="100%"
130
+ height={safeHeight}
131
+ viewBox={`0 0 ${VIEW_W} ${safeHeight}`}
132
+ preserveAspectRatio="none"
133
+ role="img"
134
+ aria-label={ariaLabel ?? describe(data, label)}
135
+ className="block w-full overflow-visible"
136
+ >
137
+ <defs>
138
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
139
+ <stop offset="0%" stopColor={color} stopOpacity={0.35} />
140
+ <stop offset="100%" stopColor={color} stopOpacity={0} />
141
+ </linearGradient>
142
+ </defs>
143
+
144
+ {showBaseline && (
145
+ <g className="text-border" aria-hidden="true">
146
+ {Array.from({ length: lines }, (_, i) => {
147
+ const y = ((i + 1) / (lines + 1)) * safeHeight
148
+ return (
149
+ <line
150
+ key={i}
151
+ x1={0}
152
+ y1={y}
153
+ x2={VIEW_W}
154
+ y2={y}
155
+ stroke="currentColor"
156
+ strokeWidth={1}
157
+ strokeDasharray="2 3"
158
+ vectorEffect="non-scaling-stroke"
159
+ />
160
+ )
161
+ })}
162
+ </g>
163
+ )}
164
+
165
+ {geometry && (
166
+ <>
167
+ <path d={geometry.area} fill={`url(#${gradientId})`} stroke="none" />
168
+ <path
169
+ d={geometry.line}
170
+ fill="none"
171
+ stroke={color}
172
+ strokeWidth={2}
173
+ strokeLinecap="round"
174
+ strokeLinejoin="round"
175
+ vectorEffect="non-scaling-stroke"
176
+ />
177
+ </>
178
+ )}
179
+ </svg>
180
+ </div>
181
+ )
182
+ }
@@ -0,0 +1,119 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export type DeltaDirection = 'up' | 'down' | 'neutral'
5
+
6
+ export interface DeltaPillProps {
7
+ /** The change itself. A number is signed + suffixed for you; a string is rendered verbatim. */
8
+ value: number | string
9
+ /** Overrides the direction inferred from a numeric `value`. Required to colour a string value. */
10
+ direction?: DeltaDirection
11
+ /** Appended to a numeric value — '%' by default. Ignored for string values. */
12
+ suffix?: string
13
+ /** Trailing context, e.g. 'vs last month'. */
14
+ label?: string
15
+ /** Prefix positive numbers with '+'. Defaults to true. */
16
+ showSign?: boolean
17
+ size?: 'sm' | 'md'
18
+ className?: string
19
+ }
20
+
21
+ /**
22
+ * Direction of a delta. Explicit `direction` always wins; otherwise a finite
23
+ * number is read by sign and everything else (strings, NaN, Infinity) is
24
+ * neutral — a pill can't claim a direction it can't derive.
25
+ */
26
+ export function resolveDeltaDirection(
27
+ value: number | string,
28
+ direction?: DeltaDirection,
29
+ ): DeltaDirection {
30
+ if (direction) return direction
31
+ if (typeof value === 'number' && Number.isFinite(value)) {
32
+ if (value > 0) return 'up'
33
+ if (value < 0) return 'down'
34
+ }
35
+ return 'neutral'
36
+ }
37
+
38
+ /** Em dash for a number that can't be shown (NaN / ±Infinity). */
39
+ export const DELTA_UNAVAILABLE = '—'
40
+
41
+ function formatDelta(
42
+ value: number | string,
43
+ suffix: string,
44
+ showSign: boolean,
45
+ ): string {
46
+ if (typeof value === 'string') return value
47
+ if (!Number.isFinite(value)) return DELTA_UNAVAILABLE
48
+ const sign = showSign && value > 0 ? '+' : ''
49
+ return `${sign}${value}${suffix}`
50
+ }
51
+
52
+ const DIRECTION_TEXT: Record<DeltaDirection, string> = {
53
+ up: 'text-success',
54
+ down: 'text-destructive',
55
+ neutral: 'text-secondary-foreground',
56
+ }
57
+
58
+ /** Screen-reader wording so direction never rests on colour alone. */
59
+ const DIRECTION_WORD: Record<DeltaDirection, string> = {
60
+ up: 'up',
61
+ down: 'down',
62
+ neutral: 'unchanged',
63
+ }
64
+
65
+ function DirectionArrow({ direction }: { direction: DeltaDirection }) {
66
+ const d =
67
+ direction === 'up'
68
+ ? 'M12 19V5M5 12l7-7 7 7'
69
+ : direction === 'down'
70
+ ? 'M12 5v14M5 12l7 7 7-7'
71
+ : 'M5 12h14'
72
+
73
+ return (
74
+ <svg
75
+ className="h-3 w-3 shrink-0"
76
+ fill="none"
77
+ viewBox="0 0 24 24"
78
+ stroke="currentColor"
79
+ strokeWidth={2.5}
80
+ aria-hidden="true"
81
+ >
82
+ <path strokeLinecap="round" strokeLinejoin="round" d={d} />
83
+ </svg>
84
+ )
85
+ }
86
+
87
+ /**
88
+ * A rounded chip carrying a signed change and its direction — "+12% vs last
89
+ * month". Used standalone or as the `delta` of a {@link StatTile}.
90
+ */
91
+ export function DeltaPill({
92
+ value,
93
+ direction,
94
+ suffix = '%',
95
+ label,
96
+ showSign = true,
97
+ size = 'md',
98
+ className,
99
+ }: DeltaPillProps) {
100
+ const resolved = resolveDeltaDirection(value, direction)
101
+ const text = formatDelta(value, suffix, showSign)
102
+
103
+ return (
104
+ <span
105
+ className={cn(
106
+ 'inline-flex items-center gap-1 rounded-full bg-secondary font-medium',
107
+ size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
108
+ DIRECTION_TEXT[resolved],
109
+ className,
110
+ )}
111
+ data-direction={resolved}
112
+ >
113
+ <DirectionArrow direction={resolved} />
114
+ <span className="sr-only">{DIRECTION_WORD[resolved]}</span>
115
+ <span className="tabular-nums">{text}</span>
116
+ {label ? <span className="opacity-80">{label}</span> : null}
117
+ </span>
118
+ )
119
+ }
@@ -0,0 +1,189 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface DonutSegment {
5
+ label: string
6
+ value: number
7
+ /** Any CSS colour. Brand palettes are the caller's business; defaults to `currentColor`. */
8
+ color?: string
9
+ }
10
+
11
+ export interface DonutChartProps {
12
+ segments: DonutSegment[]
13
+ /** Big number in the hole. */
14
+ centerValue?: React.ReactNode
15
+ /** Muted caption under `centerValue`. */
16
+ centerLabel?: string
17
+ /** Outer diameter in px. */
18
+ size?: number
19
+ /** Ring width in px — clamped so the ring always fits inside `size`. */
20
+ thickness?: number
21
+ /** Render the label/value list beside the ring. */
22
+ showLegend?: boolean
23
+ /** Formats the right-hand column of a legend row. Defaults to the raw value. */
24
+ formatLegendValue?: (segment: DonutSegment) => React.ReactNode
25
+ /** Overrides the generated description. */
26
+ ariaLabel?: string
27
+ className?: string
28
+ }
29
+
30
+ export interface DonutSlice extends DonutSegment {
31
+ /** 0..1 share of the total. */
32
+ fraction: number
33
+ /** 0..1 share consumed by the preceding slices. */
34
+ offset: number
35
+ }
36
+
37
+ /**
38
+ * Turns raw segments into ring slices. Negative values are clamped to zero
39
+ * (a ring cannot show a negative arc) and a total of zero yields no slices, so
40
+ * an all-zero or empty input renders the bare track instead of NaN arcs.
41
+ */
42
+ export function donutSlices(segments: DonutSegment[]): {
43
+ slices: DonutSlice[]
44
+ total: number
45
+ } {
46
+ const cleaned = segments.map((s) => ({
47
+ ...s,
48
+ value: Number.isFinite(s.value) ? Math.max(s.value, 0) : 0,
49
+ }))
50
+ const total = cleaned.reduce((sum, s) => sum + s.value, 0)
51
+ if (total <= 0) return { slices: [], total: 0 }
52
+
53
+ let offset = 0
54
+ const slices = cleaned.map((s) => {
55
+ const fraction = s.value / total
56
+ const slice: DonutSlice = { ...s, fraction, offset }
57
+ offset += fraction
58
+ return slice
59
+ })
60
+ return { slices, total }
61
+ }
62
+
63
+ function describe(slices: DonutSlice[], centerLabel?: string): string {
64
+ const subject = centerLabel ? `${centerLabel} breakdown` : 'Breakdown'
65
+ if (slices.length === 0) return `${subject}: no data`
66
+ const parts = slices.map(
67
+ (s) => `${s.label} ${Math.round(s.fraction * 1000) / 10}%`,
68
+ )
69
+ return `${subject}: ${parts.join(', ')}`
70
+ }
71
+
72
+ /**
73
+ * A proportional ring with a total in the middle. Pure SVG `stroke-dasharray` —
74
+ * no charting dependency, no hooks, safe to render on the server.
75
+ */
76
+ export function DonutChart({
77
+ segments,
78
+ centerValue,
79
+ centerLabel,
80
+ size = 180,
81
+ thickness = 18,
82
+ showLegend = false,
83
+ formatLegendValue,
84
+ ariaLabel,
85
+ className,
86
+ }: DonutChartProps) {
87
+ const safeSize = Math.max(size, 8)
88
+ // Keep the stroke inside the viewBox no matter what the caller asks for.
89
+ const safeThickness = Math.min(Math.max(thickness, 1), safeSize / 2)
90
+ const radius = Math.max((safeSize - safeThickness) / 2, 0.5)
91
+ const circumference = 2 * Math.PI * radius
92
+ const center = safeSize / 2
93
+
94
+ const { slices } = donutSlices(segments)
95
+ const hasCenter = centerValue !== undefined || centerLabel !== undefined
96
+
97
+ return (
98
+ <div
99
+ className={cn(
100
+ 'flex items-center gap-6',
101
+ showLegend ? 'flex-row flex-wrap' : 'flex-col',
102
+ className,
103
+ )}
104
+ >
105
+ <div
106
+ className="relative shrink-0"
107
+ style={{ width: safeSize, height: safeSize }}
108
+ >
109
+ <svg
110
+ width={safeSize}
111
+ height={safeSize}
112
+ viewBox={`0 0 ${safeSize} ${safeSize}`}
113
+ role="img"
114
+ aria-label={ariaLabel ?? describe(slices, centerLabel)}
115
+ className="block -rotate-90"
116
+ >
117
+ {/* Track — also the entire chart when there is nothing to plot. */}
118
+ <circle
119
+ cx={center}
120
+ cy={center}
121
+ r={radius}
122
+ fill="none"
123
+ stroke="currentColor"
124
+ strokeWidth={safeThickness}
125
+ className="text-border"
126
+ />
127
+ {slices.map((slice, i) => {
128
+ const dash = slice.fraction * circumference
129
+ return (
130
+ <circle
131
+ key={`${slice.label}-${i}`}
132
+ cx={center}
133
+ cy={center}
134
+ r={radius}
135
+ fill="none"
136
+ stroke={slice.color ?? 'currentColor'}
137
+ strokeWidth={safeThickness}
138
+ // A lone 100% slice gets a zero-length gap, which draws the
139
+ // full ring rather than a hairline.
140
+ strokeDasharray={`${dash} ${Math.max(circumference - dash, 0)}`}
141
+ strokeDashoffset={-slice.offset * circumference}
142
+ />
143
+ )
144
+ })}
145
+ </svg>
146
+
147
+ {hasCenter && (
148
+ <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-3 text-center">
149
+ {centerValue !== undefined && (
150
+ <span className="text-xl font-semibold tabular-nums tracking-tight text-foreground">
151
+ {centerValue}
152
+ </span>
153
+ )}
154
+ {centerLabel !== undefined && (
155
+ <span className="mt-0.5 text-xs text-muted-foreground">
156
+ {centerLabel}
157
+ </span>
158
+ )}
159
+ </div>
160
+ )}
161
+ </div>
162
+
163
+ {showLegend && segments.length > 0 && (
164
+ <ul className="min-w-0 flex-1 space-y-2">
165
+ {segments.map((segment, i) => (
166
+ <li
167
+ key={`${segment.label}-${i}`}
168
+ className="flex items-center justify-between gap-3 text-sm"
169
+ >
170
+ <span className="flex min-w-0 items-center gap-2">
171
+ <span
172
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
173
+ style={{ backgroundColor: segment.color ?? 'currentColor' }}
174
+ aria-hidden="true"
175
+ />
176
+ <span className="truncate text-muted-foreground">
177
+ {segment.label}
178
+ </span>
179
+ </span>
180
+ <span className="shrink-0 font-medium tabular-nums text-foreground">
181
+ {formatLegendValue ? formatLegendValue(segment) : segment.value}
182
+ </span>
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ )}
187
+ </div>
188
+ )
189
+ }
@@ -0,0 +1,98 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { AreaTrend } from './AreaTrend'
4
+ import { DeltaPill, type DeltaPillProps } from './DeltaPill'
5
+
6
+ export type StatTileSize = 'sm' | 'md' | 'lg' | 'xl'
7
+
8
+ export interface StatTileProps {
9
+ /** Small uppercase kicker above the numeral. */
10
+ label: string
11
+ /** The headline figure. Pre-format it (currency, compaction) — the tile only styles it. */
12
+ value: React.ReactNode
13
+ /** Muted line under the value, e.g. 'Streams · last 28 days'. */
14
+ caption?: string
15
+ /** Change chip under the caption. */
16
+ delta?: DeltaPillProps
17
+ /** Inline trend drawn to the right of the numeral. */
18
+ sparkline?: number[]
19
+ /** Any CSS colour for the sparkline; defaults to the tile's `text-primary` tint. */
20
+ sparklineColor?: string
21
+ /** Numeral scale — `lg` (text-4xl) by default, `xl` for a hero figure. */
22
+ size?: StatTileSize
23
+ /** Extra content below the delta. */
24
+ children?: React.ReactNode
25
+ className?: string
26
+ }
27
+
28
+ const VALUE_SIZE: Record<StatTileSize, string> = {
29
+ sm: 'text-2xl',
30
+ md: 'text-3xl',
31
+ lg: 'text-4xl',
32
+ xl: 'text-5xl',
33
+ }
34
+
35
+ /**
36
+ * A card built around one big display numeral: kicker, figure, optional
37
+ * caption, optional delta chip, optional inline sparkline. Presentational and
38
+ * hook-free — safe to render on the server.
39
+ */
40
+ export function StatTile({
41
+ label,
42
+ value,
43
+ caption,
44
+ delta,
45
+ sparkline,
46
+ sparklineColor,
47
+ size = 'lg',
48
+ children,
49
+ className,
50
+ }: StatTileProps) {
51
+ const hasSparkline = Array.isArray(sparkline) && sparkline.length > 0
52
+
53
+ return (
54
+ <div
55
+ className={cn(
56
+ 'rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm',
57
+ className,
58
+ )}
59
+ >
60
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
61
+ {label}
62
+ </p>
63
+
64
+ <div className="mt-2 flex items-center justify-between gap-4">
65
+ <p
66
+ className={cn(
67
+ 'min-w-0 font-semibold tabular-nums tracking-tight text-foreground',
68
+ VALUE_SIZE[size],
69
+ )}
70
+ >
71
+ {value}
72
+ </p>
73
+
74
+ {hasSparkline && (
75
+ <AreaTrend
76
+ data={sparkline}
77
+ color={sparklineColor}
78
+ height={44}
79
+ ariaLabel={`${label} trend`}
80
+ className="w-24 shrink-0 text-primary sm:w-32"
81
+ />
82
+ )}
83
+ </div>
84
+
85
+ {caption && (
86
+ <p className="mt-1 text-sm text-muted-foreground">{caption}</p>
87
+ )}
88
+
89
+ {delta && (
90
+ <div className="mt-3">
91
+ <DeltaPill size="sm" {...delta} />
92
+ </div>
93
+ )}
94
+
95
+ {children}
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,452 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { AreaTrend, areaTrendGeometry } from '../AreaTrend'
3
+ import { DeltaPill, resolveDeltaDirection, DELTA_UNAVAILABLE } from '../DeltaPill'
4
+ import { DonutChart, donutSlices } from '../DonutChart'
5
+ import { StatTile } from '../StatTile'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** Every path `d` in the rendered SVG — the thing that goes NaN when maths breaks. */
12
+ function pathData(container: HTMLElement): string[] {
13
+ return Array.from(container.querySelectorAll('path')).map(
14
+ (p) => p.getAttribute('d') ?? '',
15
+ )
16
+ }
17
+
18
+ function circleAttrs(container: HTMLElement, attr: string): string[] {
19
+ return Array.from(container.querySelectorAll('circle')).map(
20
+ (c) => c.getAttribute(attr) ?? '',
21
+ )
22
+ }
23
+
24
+ function expectNoNaN(values: string[]) {
25
+ values.forEach((v) => expect(v).not.toMatch(/NaN|Infinity/))
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // DeltaPill
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('resolveDeltaDirection', () => {
33
+ it('reads direction from the sign of a finite number', () => {
34
+ expect(resolveDeltaDirection(12)).toBe('up')
35
+ expect(resolveDeltaDirection(-3.5)).toBe('down')
36
+ })
37
+
38
+ it('treats zero as neutral', () => {
39
+ expect(resolveDeltaDirection(0)).toBe('neutral')
40
+ expect(resolveDeltaDirection(-0)).toBe('neutral')
41
+ })
42
+
43
+ it('treats a non-finite number as neutral rather than guessing', () => {
44
+ expect(resolveDeltaDirection(NaN)).toBe('neutral')
45
+ expect(resolveDeltaDirection(Infinity)).toBe('neutral')
46
+ expect(resolveDeltaDirection(-Infinity)).toBe('neutral')
47
+ })
48
+
49
+ it('treats a string as neutral unless direction is given', () => {
50
+ expect(resolveDeltaDirection('+12%')).toBe('neutral')
51
+ expect(resolveDeltaDirection('+12%', 'up')).toBe('up')
52
+ })
53
+
54
+ it('lets an explicit direction override the sign', () => {
55
+ expect(resolveDeltaDirection(-4, 'up')).toBe('up')
56
+ })
57
+ })
58
+
59
+ describe('DeltaPill', () => {
60
+ it('signs and suffixes a positive number', () => {
61
+ render(<DeltaPill value={12} />)
62
+ expect(screen.getByText('+12%')).toBeInTheDocument()
63
+ })
64
+
65
+ it('does not double-sign a negative number', () => {
66
+ render(<DeltaPill value={-3} />)
67
+ expect(screen.getByText('-3%')).toBeInTheDocument()
68
+ })
69
+
70
+ it('omits the sign when showSign is false', () => {
71
+ render(<DeltaPill value={12} showSign={false} />)
72
+ expect(screen.getByText('12%')).toBeInTheDocument()
73
+ })
74
+
75
+ it('renders a string value verbatim', () => {
76
+ render(<DeltaPill value="2.4x" />)
77
+ expect(screen.getByText('2.4x')).toBeInTheDocument()
78
+ })
79
+
80
+ it('shows a dash instead of NaN', () => {
81
+ render(<DeltaPill value={NaN} />)
82
+ expect(screen.getByText(DELTA_UNAVAILABLE)).toBeInTheDocument()
83
+ })
84
+
85
+ it('colours up as success and down as destructive', () => {
86
+ const { container: up } = render(<DeltaPill value={5} />)
87
+ expect(up.firstChild).toHaveClass('text-success')
88
+
89
+ const { container: down } = render(<DeltaPill value={-5} />)
90
+ expect(down.firstChild).toHaveClass('text-destructive')
91
+ })
92
+
93
+ it('falls back to a neutral tone at zero', () => {
94
+ const { container } = render(<DeltaPill value={0} />)
95
+ expect(container.firstChild).toHaveClass('text-secondary-foreground')
96
+ expect(container.firstChild).toHaveAttribute('data-direction', 'neutral')
97
+ })
98
+
99
+ it('names the direction for screen readers, not just in colour', () => {
100
+ render(<DeltaPill value={12} label="vs last month" />)
101
+ expect(screen.getByText('up')).toHaveClass('sr-only')
102
+ expect(screen.getByText('vs last month')).toBeInTheDocument()
103
+ })
104
+
105
+ it('sits on the secondary surface so it reads on light and dark', () => {
106
+ const { container } = render(<DeltaPill value={1} />)
107
+ expect(container.firstChild).toHaveClass('bg-secondary')
108
+ })
109
+ })
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // StatTile
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe('StatTile', () => {
116
+ it('renders the label, value and caption', () => {
117
+ render(
118
+ <StatTile
119
+ label="Estimated this month"
120
+ value="$48,500"
121
+ caption="Streams · last 28 days"
122
+ />,
123
+ )
124
+ expect(screen.getByText('Estimated this month')).toBeInTheDocument()
125
+ expect(screen.getByText('$48,500')).toBeInTheDocument()
126
+ expect(screen.getByText('Streams · last 28 days')).toBeInTheDocument()
127
+ })
128
+
129
+ it('defaults to the large numeral and honours an explicit size', () => {
130
+ const { container: def } = render(<StatTile label="A" value="1" />)
131
+ expect(def.querySelector('p.text-4xl')).toBeInTheDocument()
132
+
133
+ const { container: xl } = render(<StatTile label="A" value="1" size="xl" />)
134
+ expect(xl.querySelector('p.text-5xl')).toBeInTheDocument()
135
+ })
136
+
137
+ it('renders a delta pill when given one', () => {
138
+ render(
139
+ <StatTile
140
+ label="Earnings"
141
+ value="$48,500"
142
+ delta={{ value: 12, label: 'vs last month' }}
143
+ />,
144
+ )
145
+ expect(screen.getByText('+12%')).toBeInTheDocument()
146
+ expect(screen.getByText('vs last month')).toBeInTheDocument()
147
+ })
148
+
149
+ it('renders an inline sparkline with a meaningful label', () => {
150
+ render(<StatTile label="Streams" value="18.4M" sparkline={[1, 4, 2, 8]} />)
151
+ expect(screen.getByRole('img', { name: /Streams trend/ })).toBeInTheDocument()
152
+ })
153
+
154
+ it('omits the sparkline for an empty series', () => {
155
+ render(<StatTile label="Streams" value="18.4M" sparkline={[]} />)
156
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
157
+ })
158
+
159
+ it('styles against theme tokens, not a fixed grey', () => {
160
+ const { container } = render(<StatTile label="A" value="1" />)
161
+ expect(container.firstChild).toHaveClass('bg-card')
162
+ expect(container.firstChild).toHaveClass('border-border')
163
+ expect(screen.getByText('A')).toHaveClass('text-muted-foreground')
164
+ expect(screen.getByText('1')).toHaveClass('text-foreground')
165
+ })
166
+ })
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // AreaTrend
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe('areaTrendGeometry', () => {
173
+ it('returns null for an empty series', () => {
174
+ expect(areaTrendGeometry([], 100)).toBeNull()
175
+ })
176
+
177
+ it('centres a flat series instead of dividing by a zero range', () => {
178
+ const geo = areaTrendGeometry([5, 5, 5], 100)
179
+ expect(geo).not.toBeNull()
180
+ expect(geo!.line).toBe('M 0,50 L 50,50 L 100,50')
181
+ })
182
+
183
+ it('centres an all-zero series the same way', () => {
184
+ const geo = areaTrendGeometry([0, 0, 0, 0], 80)
185
+ expectNoNaN([geo!.line])
186
+ expect(geo!.line).toBe('M 0,40 L 33.33333333333333,40 L 66.66666666666666,40 L 100,40')
187
+ })
188
+
189
+ it('draws a single point as a level rule across the box', () => {
190
+ const geo = areaTrendGeometry([7], 100)
191
+ expect(geo!.line).toBe('M 0,50 L 100,50')
192
+ })
193
+
194
+ it('normalises a series that crosses zero', () => {
195
+ const geo = areaTrendGeometry([-10, 0, 10], 100)
196
+ expectNoNaN([geo!.line, geo!.area])
197
+ // Lowest value sits at the bottom, highest at the top.
198
+ expect(geo!.line).toBe('M 0,98 L 50,50 L 100,2')
199
+ })
200
+
201
+ it('ignores non-finite points rather than emitting NaN', () => {
202
+ const geo = areaTrendGeometry([1, NaN, 3], 100)
203
+ expectNoNaN([geo!.line, geo!.area])
204
+ })
205
+ })
206
+
207
+ describe('AreaTrend', () => {
208
+ it('exposes an accessible description of the shape', () => {
209
+ render(<AreaTrend data={[1, 2, 3]} label="Spotify" />)
210
+ expect(
211
+ screen.getByRole('img', { name: /Spotify trend: rising over 3 points, from 1 to 3/ }),
212
+ ).toBeInTheDocument()
213
+ })
214
+
215
+ it('describes a falling series', () => {
216
+ render(<AreaTrend data={[9, 4, 1]} />)
217
+ expect(screen.getByRole('img', { name: /falling/ })).toBeInTheDocument()
218
+ })
219
+
220
+ it('renders an empty frame for no data without crashing', () => {
221
+ const { container } = render(<AreaTrend data={[]} />)
222
+ expect(screen.getByRole('img', { name: /no data/ })).toBeInTheDocument()
223
+ expect(pathData(container)).toEqual([])
224
+ })
225
+
226
+ it('never emits NaN coordinates for degenerate input', () => {
227
+ const cases: number[][] = [[], [0], [0, 0, 0], [4, 4], [-5, -5], [-2, 7, -9]]
228
+ cases.forEach((data) => {
229
+ const { container } = render(<AreaTrend data={data} />)
230
+ expectNoNaN(pathData(container))
231
+ })
232
+ })
233
+
234
+ it('tints from currentColor by default so the caller controls the colour', () => {
235
+ const { container } = render(<AreaTrend data={[1, 2]} />)
236
+ const line = container.querySelectorAll('path')[1]
237
+ expect(line).toHaveAttribute('stroke', 'currentColor')
238
+ expect(container.querySelector('stop')).toHaveAttribute(
239
+ 'stop-color',
240
+ 'currentColor',
241
+ )
242
+ })
243
+
244
+ it('accepts an explicit colour for both the line and the gradient', () => {
245
+ const { container } = render(<AreaTrend data={[1, 2]} color="#22C55E" />)
246
+ expect(container.querySelectorAll('path')[1]).toHaveAttribute('stroke', '#22C55E')
247
+ Array.from(container.querySelectorAll('stop')).forEach((stop) =>
248
+ expect(stop).toHaveAttribute('stop-color', '#22C55E'),
249
+ )
250
+ })
251
+
252
+ it('fades the fill from 35% to transparent', () => {
253
+ const { container } = render(<AreaTrend data={[1, 2]} />)
254
+ const stops = container.querySelectorAll('stop')
255
+ expect(stops[0]).toHaveAttribute('stop-opacity', '0.35')
256
+ expect(stops[1]).toHaveAttribute('stop-opacity', '0')
257
+ })
258
+
259
+ it('generates the same gradient id on every render so SSR matches the client', () => {
260
+ const { container: a } = render(<AreaTrend data={[1, 2]} color="#22C55E" />)
261
+ const { container: b } = render(<AreaTrend data={[9, 4]} color="#22C55E" />)
262
+ expect(a.querySelector('linearGradient')!.id).toBe(
263
+ b.querySelector('linearGradient')!.id,
264
+ )
265
+ })
266
+
267
+ it('draws baselines only when asked, and clamps the count', () => {
268
+ const { container: off } = render(<AreaTrend data={[1, 2]} />)
269
+ expect(off.querySelectorAll('line')).toHaveLength(0)
270
+
271
+ const { container: on } = render(<AreaTrend data={[1, 2]} showBaseline />)
272
+ expect(on.querySelectorAll('line')).toHaveLength(3)
273
+
274
+ const { container: clamped } = render(
275
+ <AreaTrend data={[1, 2]} showBaseline baselineCount={0} />,
276
+ )
277
+ expect(clamped.querySelectorAll('line')).toHaveLength(1)
278
+ })
279
+
280
+ it('renders the optional header row', () => {
281
+ render(<AreaTrend data={[1, 2]} label="Apple Music" value="4.1M" />)
282
+ expect(screen.getByText('Apple Music')).toBeInTheDocument()
283
+ expect(screen.getByText('4.1M')).toBeInTheDocument()
284
+ })
285
+ })
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // DonutChart
289
+ // ---------------------------------------------------------------------------
290
+
291
+ describe('donutSlices', () => {
292
+ it('splits values into proportional fractions with running offsets', () => {
293
+ const { slices, total } = donutSlices([
294
+ { label: 'a', value: 3 },
295
+ { label: 'b', value: 1 },
296
+ ])
297
+ expect(total).toBe(4)
298
+ expect(slices.map((s) => s.fraction)).toEqual([0.75, 0.25])
299
+ expect(slices.map((s) => s.offset)).toEqual([0, 0.75])
300
+ })
301
+
302
+ it('gives a lone segment the whole ring', () => {
303
+ const { slices } = donutSlices([{ label: 'only', value: 42 }])
304
+ expect(slices).toHaveLength(1)
305
+ expect(slices[0].fraction).toBe(1)
306
+ expect(slices[0].offset).toBe(0)
307
+ })
308
+
309
+ it('returns no slices for an empty input', () => {
310
+ expect(donutSlices([])).toEqual({ slices: [], total: 0 })
311
+ })
312
+
313
+ it('returns no slices when every value is zero', () => {
314
+ expect(
315
+ donutSlices([
316
+ { label: 'a', value: 0 },
317
+ { label: 'b', value: 0 },
318
+ ]).slices,
319
+ ).toEqual([])
320
+ })
321
+
322
+ it('clamps negative values to zero instead of inverting the ring', () => {
323
+ const { slices, total } = donutSlices([
324
+ { label: 'a', value: -5 },
325
+ { label: 'b', value: 5 },
326
+ ])
327
+ expect(total).toBe(5)
328
+ expect(slices.map((s) => s.fraction)).toEqual([0, 1])
329
+ })
330
+
331
+ it('treats an all-negative input as empty', () => {
332
+ expect(
333
+ donutSlices([
334
+ { label: 'a', value: -1 },
335
+ { label: 'b', value: -2 },
336
+ ]).slices,
337
+ ).toEqual([])
338
+ })
339
+
340
+ it('ignores non-finite values', () => {
341
+ const { total } = donutSlices([
342
+ { label: 'a', value: NaN },
343
+ { label: 'b', value: 2 },
344
+ ])
345
+ expect(total).toBe(2)
346
+ })
347
+ })
348
+
349
+ describe('DonutChart', () => {
350
+ const segments = [
351
+ { label: 'Streaming', value: 22400, color: '#22B8F0' },
352
+ { label: 'Video', value: 6800, color: '#22C55E' },
353
+ ]
354
+
355
+ it('renders the centre value and caption', () => {
356
+ render(
357
+ <DonutChart
358
+ segments={segments}
359
+ centerValue="$29,200"
360
+ centerLabel="Revenue"
361
+ />,
362
+ )
363
+ expect(screen.getByText('$29,200')).toBeInTheDocument()
364
+ expect(screen.getByText('Revenue')).toBeInTheDocument()
365
+ })
366
+
367
+ it('describes the split for screen readers', () => {
368
+ render(<DonutChart segments={segments} centerLabel="Revenue" />)
369
+ expect(
370
+ screen.getByRole('img', {
371
+ name: /Revenue breakdown: Streaming 76.7%, Video 23.3%/,
372
+ }),
373
+ ).toBeInTheDocument()
374
+ })
375
+
376
+ it('draws one arc per segment plus the track', () => {
377
+ const { container } = render(<DonutChart segments={segments} />)
378
+ expect(container.querySelectorAll('circle')).toHaveLength(3)
379
+ })
380
+
381
+ it('draws a full ring with a zero-length gap for one 100% segment', () => {
382
+ const { container } = render(
383
+ <DonutChart segments={[{ label: 'All', value: 10 }]} size={100} thickness={10} />,
384
+ )
385
+ const circumference = 2 * Math.PI * 45
386
+ const dashes = circleAttrs(container, 'stroke-dasharray').filter(Boolean)
387
+ expect(dashes).toHaveLength(1)
388
+ expect(dashes[0]).toBe(`${circumference} 0`)
389
+ })
390
+
391
+ it('renders only the track for an empty or all-zero input', () => {
392
+ const { container: empty } = render(<DonutChart segments={[]} />)
393
+ expect(empty.querySelectorAll('circle')).toHaveLength(1)
394
+ expect(screen.getByRole('img', { name: /no data/ })).toBeInTheDocument()
395
+
396
+ const { container: zeros } = render(
397
+ <DonutChart segments={[{ label: 'a', value: 0 }]} />,
398
+ )
399
+ expect(zeros.querySelectorAll('circle')).toHaveLength(1)
400
+ })
401
+
402
+ it('never emits NaN geometry for degenerate input', () => {
403
+ const cases = [
404
+ [],
405
+ [{ label: 'a', value: 0 }],
406
+ [{ label: 'a', value: -1 }],
407
+ [{ label: 'a', value: 1 }],
408
+ ]
409
+ cases.forEach((segs) => {
410
+ const { container } = render(<DonutChart segments={segs} />)
411
+ expectNoNaN([
412
+ ...circleAttrs(container, 'r'),
413
+ ...circleAttrs(container, 'stroke-dasharray'),
414
+ ...circleAttrs(container, 'stroke-dashoffset'),
415
+ ...circleAttrs(container, 'stroke-width'),
416
+ ])
417
+ })
418
+ })
419
+
420
+ it('keeps the ring inside the box when thickness exceeds the size', () => {
421
+ const { container } = render(
422
+ <DonutChart segments={segments} size={40} thickness={999} />,
423
+ )
424
+ const r = Number(circleAttrs(container, 'r')[0])
425
+ const width = Number(circleAttrs(container, 'stroke-width')[0])
426
+ expect(width).toBe(20)
427
+ expect(r).toBe(10)
428
+ expect(r + width / 2).toBeLessThanOrEqual(20)
429
+ })
430
+
431
+ it('shows a legend with each label and value when asked', () => {
432
+ render(
433
+ <DonutChart
434
+ segments={segments}
435
+ showLegend
436
+ formatLegendValue={(s) => `$${s.value.toLocaleString()}`}
437
+ />,
438
+ )
439
+ expect(screen.getByText('Streaming')).toBeInTheDocument()
440
+ expect(screen.getByText('$22,400')).toBeInTheDocument()
441
+ expect(screen.getByText('$6,800')).toBeInTheDocument()
442
+ })
443
+
444
+ it('uses caller colours for the arcs and falls back to currentColor', () => {
445
+ const { container } = render(
446
+ <DonutChart segments={[{ label: 'a', value: 1, color: '#FF0000' }, { label: 'b', value: 1 }]} />,
447
+ )
448
+ const strokes = circleAttrs(container, 'stroke')
449
+ expect(strokes).toContain('#FF0000')
450
+ expect(strokes.filter((s) => s === 'currentColor').length).toBeGreaterThan(0)
451
+ })
452
+ })
@@ -18,3 +18,15 @@ export type { PipelineFunnelProps, FunnelStage } from './PipelineFunnel'
18
18
 
19
19
  export { TopCampaigns } from './TopCampaigns'
20
20
  export type { TopCampaignsProps, TopCampaign } from './TopCampaigns'
21
+
22
+ export { StatTile } from './StatTile'
23
+ export type { StatTileProps, StatTileSize } from './StatTile'
24
+
25
+ export { DeltaPill, resolveDeltaDirection, DELTA_UNAVAILABLE } from './DeltaPill'
26
+ export type { DeltaPillProps, DeltaDirection } from './DeltaPill'
27
+
28
+ export { DonutChart, donutSlices } from './DonutChart'
29
+ export type { DonutChartProps, DonutSegment, DonutSlice } from './DonutChart'
30
+
31
+ export { AreaTrend, areaTrendGeometry } from './AreaTrend'
32
+ export type { AreaTrendProps } from './AreaTrend'
@@ -17,6 +17,8 @@ export const THEME_TOKENS = {
17
17
  '--accent-foreground',
18
18
  '--destructive',
19
19
  '--destructive-foreground',
20
+ '--success',
21
+ '--success-foreground',
20
22
  '--border',
21
23
  '--input',
22
24
  '--ring',
@@ -28,6 +28,10 @@ module.exports = {
28
28
  DEFAULT: "hsl(var(--destructive))",
29
29
  foreground: "hsl(var(--destructive-foreground))",
30
30
  },
31
+ success: {
32
+ DEFAULT: "hsl(var(--success, 142.1 76.2% 36.3%))",
33
+ foreground: "hsl(var(--success-foreground, 210 40% 98%))",
34
+ },
31
35
  muted: {
32
36
  DEFAULT: "hsl(var(--muted))",
33
37
  foreground: "hsl(var(--muted-foreground))",
@@ -37,6 +37,12 @@ const preset: Partial<Config> = {
37
37
  DEFAULT: 'hsl(var(--destructive))',
38
38
  foreground: 'hsl(var(--destructive-foreground))',
39
39
  },
40
+ // Fallback channels keep `text-success` legible in apps that predate
41
+ // the token; apps that declare --success override it as usual.
42
+ success: {
43
+ DEFAULT: 'hsl(var(--success, 142.1 76.2% 36.3%))',
44
+ foreground: 'hsl(var(--success-foreground, 210 40% 98%))',
45
+ },
40
46
  border: 'hsl(var(--border))',
41
47
  input: 'hsl(var(--input))',
42
48
  ring: 'hsl(var(--ring))',
@@ -34,6 +34,12 @@ module.exports = {
34
34
  DEFAULT: 'hsl(var(--destructive))',
35
35
  foreground: 'hsl(var(--destructive-foreground))',
36
36
  },
37
+ // Fallback channels keep `text-success` legible in apps that predate
38
+ // the token; apps that declare --success override it as usual.
39
+ success: {
40
+ DEFAULT: 'hsl(var(--success, 142.1 76.2% 36.3%))',
41
+ foreground: 'hsl(var(--success-foreground, 210 40% 98%))',
42
+ },
37
43
  border: 'hsl(var(--border))',
38
44
  input: 'hsl(var(--input))',
39
45
  ring: 'hsl(var(--ring))',