@refraction-ui/astro 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/action-list/action-list.styles.ts +33 -0
  2. package/dist/action-list/action-list.ts +31 -0
  3. package/dist/action-list/index.ts +16 -0
  4. package/dist/analytics-sink-app-insights/app-insights-sink.ts +1 -1
  5. package/dist/analytics-sink-posthog/client-sdk-sink.ts +1 -1
  6. package/dist/analytics-sink-posthog/replay.ts +1 -1
  7. package/dist/astro-action-list/ActionList.astro +12 -0
  8. package/dist/astro-action-list/ActionListItem.astro +62 -0
  9. package/dist/astro-action-list/index.ts +1 -0
  10. package/dist/astro-avatar/Avatar.astro +5 -4
  11. package/dist/astro-callout/Callout.astro +4 -1
  12. package/dist/astro-card/Card.astro +3 -2
  13. package/dist/astro-card/CardTitle.astro +6 -4
  14. package/dist/astro-data-table/DataTable.astro +7 -3
  15. package/dist/astro-funnel-chart/FunnelChart.astro +64 -0
  16. package/dist/astro-funnel-chart/index.ts +1 -0
  17. package/dist/astro-line-chart/LineChart.astro +82 -0
  18. package/dist/astro-line-chart/index.ts +1 -0
  19. package/dist/astro-native-select/NativeSelect.astro +26 -0
  20. package/dist/astro-native-select/index.ts +1 -0
  21. package/dist/astro-page-header/PageHeader.astro +46 -0
  22. package/dist/astro-page-header/index.ts +1 -0
  23. package/dist/astro-stat-grid/StatGrid.astro +23 -19
  24. package/dist/astro-table/Table.astro +21 -0
  25. package/dist/astro-table/TableCaption.astro +9 -0
  26. package/dist/astro-table/TableCell.astro +13 -0
  27. package/dist/astro-table/TableHead.astro +13 -0
  28. package/dist/astro-table/TableRow.astro +9 -0
  29. package/dist/astro-table/index.ts +1 -0
  30. package/dist/avatar/avatar.styles.ts +7 -2
  31. package/dist/avatar/avatar.ts +8 -3
  32. package/dist/avatar/index.ts +1 -0
  33. package/dist/badge/badge.styles.ts +6 -1
  34. package/dist/badge/badge.ts +24 -2
  35. package/dist/button/button.styles.ts +10 -4
  36. package/dist/button/button.ts +4 -1
  37. package/dist/callout/callout.ts +12 -3
  38. package/dist/card/card.styles.ts +10 -1
  39. package/dist/card/card.ts +14 -0
  40. package/dist/card/index.ts +3 -0
  41. package/dist/data-table/data-table.styles.ts +18 -0
  42. package/dist/data-table/data-table.ts +49 -7
  43. package/dist/data-table/index.ts +8 -0
  44. package/dist/diff-viewer/diff-viewer.styles.ts +3 -0
  45. package/dist/funnel-chart/funnel-chart.styles.ts +16 -0
  46. package/dist/funnel-chart/funnel-chart.ts +68 -0
  47. package/dist/funnel-chart/index.ts +10 -0
  48. package/dist/index.ts +6 -0
  49. package/dist/input/input.styles.ts +2 -2
  50. package/dist/line-chart/index.ts +20 -0
  51. package/dist/line-chart/line-chart.styles.ts +16 -0
  52. package/dist/line-chart/line-chart.ts +215 -0
  53. package/dist/logger/faro-engine.ts +2 -2
  54. package/dist/native-select/index.ts +6 -0
  55. package/dist/native-select/native-select.styles.ts +30 -0
  56. package/dist/native-select/native-select.ts +5 -0
  57. package/dist/page-header/index.ts +15 -0
  58. package/dist/page-header/page-header.styles.ts +21 -0
  59. package/dist/page-header/page-header.ts +27 -0
  60. package/dist/progress-display/progress-display.styles.ts +1 -1
  61. package/dist/select/index.ts +2 -0
  62. package/dist/select/select.styles.ts +9 -3
  63. package/dist/select/select.ts +11 -3
  64. package/dist/separator/index.ts +2 -0
  65. package/dist/separator/separator.styles.ts +17 -2
  66. package/dist/shared/cn.ts +6 -3
  67. package/dist/slide-viewer/slide-viewer.styles.ts +2 -0
  68. package/dist/stat-grid/index.ts +9 -0
  69. package/dist/stat-grid/stat-grid.styles.ts +73 -6
  70. package/dist/stat-grid/stat-grid.ts +25 -5
  71. package/dist/sticky-note/sticky-note.styles.ts +2 -0
  72. package/dist/table/index.ts +16 -0
  73. package/dist/table/table.styles.ts +72 -0
  74. package/dist/table/table.ts +24 -0
  75. package/dist/tabs/index.ts +1 -0
  76. package/dist/tabs/tabs.ts +29 -0
  77. package/dist/textarea/textarea.styles.ts +2 -2
  78. package/package.json +7 -1
@@ -0,0 +1,215 @@
1
+ export interface LineChartSeries {
2
+ /** Stable key for the series. */
3
+ id: string
4
+ /** Legend / tooltip name. */
5
+ name: string
6
+ /**
7
+ * Any CSS colour (a token `hsl(var(--…))` or a data colour). Defaults to the
8
+ * theme's chart palette (`--chart-1` … `--chart-5`) by series position.
9
+ */
10
+ color?: string
11
+ /** One value per label, in label order. */
12
+ data: number[]
13
+ }
14
+
15
+ export interface LineChartPadding {
16
+ top: number
17
+ right: number
18
+ bottom: number
19
+ left: number
20
+ }
21
+
22
+ export interface LineChartOptions {
23
+ series: LineChartSeries[]
24
+ labels: string[]
25
+ /** viewBox width — the SVG scales to its container's width. */
26
+ width: number
27
+ /** viewBox height. */
28
+ height: number
29
+ padding?: Partial<LineChartPadding>
30
+ /** Target number of gridline intervals on the y-axis (the nice scale may use fewer). */
31
+ tickCount?: number
32
+ /** Headroom multiplier above the largest value. */
33
+ headroom?: number
34
+ /**
35
+ * Show every n-th x label (the last label is always shown). Defaults to
36
+ * thinning so labels are at least ~48 viewBox units apart.
37
+ */
38
+ labelEvery?: number
39
+ }
40
+
41
+ export interface LineChartTick {
42
+ value: number
43
+ y: number
44
+ }
45
+
46
+ export interface LineChartXLabel {
47
+ index: number
48
+ label: string
49
+ x: number
50
+ }
51
+
52
+ export interface LineChartPath {
53
+ id: string
54
+ /** Resolved stroke colour. */
55
+ color: string
56
+ /** The stroke path. */
57
+ line: string
58
+ /** The line closed down to the zero baseline, for an area fill. */
59
+ area: string
60
+ }
61
+
62
+ export interface LineChartColumn {
63
+ index: number
64
+ x: number
65
+ width: number
66
+ }
67
+
68
+ export interface LineChartGeometry {
69
+ width: number
70
+ height: number
71
+ padding: LineChartPadding
72
+ /** Top of the shared y-scale. */
73
+ max: number
74
+ ticks: LineChartTick[]
75
+ xLabels: LineChartXLabel[]
76
+ paths: LineChartPath[]
77
+ /** One hover/focus column per category. */
78
+ columns: LineChartColumn[]
79
+ x: (index: number) => number
80
+ y: (value: number) => number
81
+ /** Top and bottom of the plot area, for a crosshair. */
82
+ plotTop: number
83
+ plotBottom: number
84
+ }
85
+
86
+ export const DEFAULT_LINE_CHART_PADDING: LineChartPadding = {
87
+ top: 14,
88
+ right: 14,
89
+ bottom: 24,
90
+ left: 40,
91
+ }
92
+
93
+ export const DEFAULT_LINE_CHART_WIDTH = 760
94
+ export const DEFAULT_LINE_CHART_HEIGHT = 230
95
+ const DEFAULT_TICK_COUNT = 4
96
+ const DEFAULT_HEADROOM = 1.08
97
+ /** Minimum horizontal room per x label when thinning automatically. */
98
+ const MIN_LABEL_SPACING = 48
99
+ /** Number of `--chart-N` tokens the theme defines. */
100
+ const CHART_TOKEN_COUNT = 5
101
+
102
+ /** The theme chart colour for the series at `index` (wraps around). */
103
+ export function lineChartColor(index: number): string {
104
+ return `hsl(var(--chart-${(index % CHART_TOKEN_COUNT) + 1}))`
105
+ }
106
+
107
+ const NICE_STEPS = [1, 2, 5, 10]
108
+
109
+ /**
110
+ * A "nice" y-scale: a 1-2-5 × 10^k step so there are at most `tickCount`
111
+ * intervals up to `rawMax`, and the max rounded up to a whole step. Tick
112
+ * values are therefore round numbers by construction (0, 2000, 4000, …) —
113
+ * never 5999.9999 — so any tick formatter prints clean labels.
114
+ */
115
+ export function niceScale(rawMax: number, tickCount: number): { max: number; step: number; ticks: number[] } {
116
+ const intervals = Math.max(1, Math.floor(tickCount))
117
+ if (!(rawMax > 0)) return { max: 1, step: 1 / intervals, ticks: Array.from({ length: intervals + 1 }, (_, i) => i / intervals) }
118
+ const target = rawMax / intervals
119
+ const magnitude = 10 ** Math.floor(Math.log10(target))
120
+ const step = NICE_STEPS.map((m) => m * magnitude).find((candidate) => candidate >= target) ?? 10 * magnitude
121
+ const count = Math.ceil(rawMax / step - 1e-9)
122
+ // Strip binary floating-point noise (e.g. 0.1 * 3) to the step's precision.
123
+ const decimals = Math.max(0, -Math.floor(Math.log10(step)))
124
+ const clean = (value: number) => Number(value.toFixed(decimals))
125
+ const ticks = Array.from({ length: count + 1 }, (_, i) => clean(i * step))
126
+ return { max: ticks[ticks.length - 1], step: clean(step), ticks }
127
+ }
128
+
129
+ function pathFrom(points: Array<[number, number]>): string {
130
+ return points.map(([px, py], i) => (i ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1)).join(' ')
131
+ }
132
+
133
+ /**
134
+ * Pure geometry for a responsive multi-series line chart. Every series shares
135
+ * ONE y-scale (zero baseline to the largest value plus headroom), so series are
136
+ * comparable. Coordinates are in viewBox units; the SVG scales to its container.
137
+ */
138
+ export function computeLineChart(options: LineChartOptions): LineChartGeometry {
139
+ const {
140
+ series,
141
+ labels,
142
+ width,
143
+ height,
144
+ tickCount = DEFAULT_TICK_COUNT,
145
+ headroom = DEFAULT_HEADROOM,
146
+ } = options
147
+ const padding = { ...DEFAULT_LINE_CHART_PADDING, ...options.padding }
148
+ const n = labels.length
149
+ const values = series.flatMap((s) => s.data)
150
+ const peak = values.length ? Math.max(...values) : 0
151
+ const scale = niceScale(peak > 0 ? peak * headroom : 0, tickCount)
152
+ const max = scale.max
153
+ const plotW = width - padding.left - padding.right
154
+ const plotTop = padding.top
155
+ const plotBottom = height - padding.bottom
156
+ const plotH = plotBottom - plotTop
157
+
158
+ const labelEvery =
159
+ options.labelEvery ??
160
+ Math.max(1, Math.ceil(n / Math.max(1, Math.floor(plotW / MIN_LABEL_SPACING))))
161
+
162
+ const x = (index: number) => padding.left + (n > 1 ? (index / (n - 1)) * plotW : plotW / 2)
163
+ const y = (value: number) => plotTop + (1 - value / max) * plotH
164
+
165
+ const ticks: LineChartTick[] = scale.ticks.map((value) => ({ value, y: y(value) }))
166
+
167
+ const xLabels: LineChartXLabel[] = labels
168
+ .map((label, index) => ({ index, label, x: x(index) }))
169
+ .filter(({ index }) => index % labelEvery === 0 || index === n - 1)
170
+
171
+ const paths: LineChartPath[] = series.map((s, si) => {
172
+ const points = s.data.map((v, i): [number, number] => [x(i), y(v)])
173
+ const line = pathFrom(points)
174
+ const first = points.length ? points[0][0] : x(0)
175
+ const last = points.length ? points[points.length - 1][0] : x(0)
176
+ const base = y(0).toFixed(1)
177
+ const area = `${line} L ${last.toFixed(1)} ${base} L ${first.toFixed(1)} ${base} Z`
178
+ return { id: s.id, color: s.color ?? lineChartColor(si), line, area }
179
+ })
180
+
181
+ const colW = n > 1 ? plotW / (n - 1) : plotW
182
+ const columns: LineChartColumn[] = labels.map((_, index) => ({
183
+ index,
184
+ x: x(index) - colW / 2,
185
+ width: colW,
186
+ }))
187
+
188
+ return { width, height, padding, max, ticks, xLabels, paths, columns, x, y, plotTop, plotBottom }
189
+ }
190
+
191
+ /** Where a tooltip for `index` sits horizontally, as a percent of the chart width. */
192
+ export function tooltipLeftPercent(geometry: LineChartGeometry, index: number): number {
193
+ return (geometry.x(index) / geometry.width) * 100
194
+ }
195
+
196
+ /**
197
+ * The category index a keyboard key moves to from `current` (null = nothing
198
+ * active yet). Returns `current` unchanged for keys the chart doesn't handle.
199
+ */
200
+ export function nextLineChartIndex(current: number | null, key: string, count: number): number | null {
201
+ if (count === 0) return null
202
+ const last = count - 1
203
+ switch (key) {
204
+ case 'ArrowRight':
205
+ return current == null ? 0 : Math.min(current + 1, last)
206
+ case 'ArrowLeft':
207
+ return current == null ? last : Math.max(current - 1, 0)
208
+ case 'Home':
209
+ return 0
210
+ case 'End':
211
+ return last
212
+ default:
213
+ return current
214
+ }
215
+ }
@@ -74,11 +74,11 @@ async function loadFaroTransport(
74
74
  // Indirected so bundlers keep these as runtime-optional dynamic imports.
75
75
  const sdkName = '@grafana/faro-web-sdk'
76
76
  const tracingName = '@grafana/faro-web-tracing'
77
- const sdk = (await import(/* @vite-ignore */ sdkName)) as {
77
+ const sdk = (await import(/* webpackIgnore: true */ /* @vite-ignore */ sdkName)) as {
78
78
  initializeFaro: (cfg: unknown) => unknown
79
79
  getWebInstrumentations: () => unknown[]
80
80
  }
81
- const tracing = (await import(/* @vite-ignore */ tracingName)) as {
81
+ const tracing = (await import(/* webpackIgnore: true */ /* @vite-ignore */ tracingName)) as {
82
82
  TracingInstrumentation: new () => unknown
83
83
  }
84
84
 
@@ -0,0 +1,6 @@
1
+ export { nativeSelectChevronPath, type NativeSelectSize } from './native-select.js'
2
+ export {
3
+ nativeSelectWrapperClass,
4
+ nativeSelectVariants,
5
+ nativeSelectIconClass,
6
+ } from './native-select.styles.js'
@@ -0,0 +1,30 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /** Wrapper that positions the chevron over the select. */
4
+ export const nativeSelectWrapperClass = 'relative inline-flex min-w-0 items-center'
5
+
6
+ /**
7
+ * The `<select>`: the platform's own listbox (mobile pickers, type-to-select,
8
+ * form submission, `change` events) with the input look. Size-owned utilities
9
+ * live only in the size variants so `size` always wins.
10
+ */
11
+ export const nativeSelectVariants = cva({
12
+ base:
13
+ 'w-full cursor-pointer appearance-none rounded-md border border-input bg-background text-foreground shadow-sm ' +
14
+ 'ring-offset-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ' +
15
+ 'disabled:cursor-not-allowed disabled:opacity-50',
16
+ variants: {
17
+ size: {
18
+ sm: 'h-8 pl-2.5 pr-8 text-xs',
19
+ default: 'h-9 pl-3 pr-9 text-sm',
20
+ lg: 'h-10 pl-3 pr-10 text-base',
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ size: 'default',
25
+ },
26
+ })
27
+
28
+ /** The chevron: decorative, and never eats the click meant for the select. */
29
+ export const nativeSelectIconClass =
30
+ 'pointer-events-none absolute right-2.5 h-4 w-4 shrink-0 text-muted-foreground'
@@ -0,0 +1,5 @@
1
+ /** Visual size of a NativeSelect. */
2
+ export type NativeSelectSize = 'sm' | 'default' | 'lg'
3
+
4
+ /** The chevron's path (a down chevron), so adapters ship no icon dependency. */
5
+ export const nativeSelectChevronPath = 'm6 9 6 6 6-6'
@@ -0,0 +1,15 @@
1
+ export {
2
+ createPageHeader,
3
+ type PageHeaderTitleLevel,
4
+ type PageHeaderProps,
5
+ type PageHeaderAPI,
6
+ } from './page-header.js'
7
+
8
+ export {
9
+ pageHeaderClass,
10
+ pageHeaderTextClass,
11
+ pageHeaderKickerClass,
12
+ pageHeaderTitleClass,
13
+ pageHeaderDescriptionClass,
14
+ pageHeaderActionsClass,
15
+ } from './page-header.styles.js'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Root: text cluster on the leading edge, actions on the trailing edge; wraps
3
+ * on narrow screens. Carries no outer margin — the page layout spaces it.
4
+ */
5
+ export const pageHeaderClass = 'flex flex-wrap items-start justify-between gap-4'
6
+
7
+ /** Text cluster (kicker, title, description). */
8
+ export const pageHeaderTextClass = 'flex min-w-0 flex-1 flex-col gap-1'
9
+
10
+ /** Small uppercase eyebrow above the title. */
11
+ export const pageHeaderKickerClass =
12
+ 'break-words text-[11px] uppercase tracking-wider text-muted-foreground'
13
+
14
+ /** Title — app-sized, not a marketing display size. */
15
+ export const pageHeaderTitleClass = 'break-words text-xl font-semibold text-foreground'
16
+
17
+ /** Supporting copy under the title, capped at a readable measure. */
18
+ export const pageHeaderDescriptionClass = 'max-w-prose text-sm text-muted-foreground'
19
+
20
+ /** Trailing actions row. */
21
+ export const pageHeaderActionsClass = 'flex flex-none flex-wrap items-center gap-2'
@@ -0,0 +1,27 @@
1
+ /** Heading element used for the page title. */
2
+ export type PageHeaderTitleLevel = 'h1' | 'h2' | 'h3'
3
+
4
+ export interface PageHeaderProps {
5
+ /** Whether an actions slot will be rendered. */
6
+ hasActions?: boolean
7
+ }
8
+
9
+ export interface PageHeaderAPI {
10
+ /** Data attributes for styling hooks. */
11
+ dataAttributes: Record<string, string>
12
+ }
13
+
14
+ /**
15
+ * Build the framework-agnostic data attributes for a PageHeader — the heading
16
+ * cluster at the top of an application page (kicker, title, description and a
17
+ * trailing actions row). Unlike SectionHead (a centred marketing heading) it is
18
+ * sized for dense app chrome and never centres.
19
+ */
20
+ export function createPageHeader(props: PageHeaderProps = {}): PageHeaderAPI {
21
+ return {
22
+ dataAttributes: {
23
+ 'data-slot': 'page-header',
24
+ 'data-has-actions': props.hasActions ? 'true' : 'false',
25
+ },
26
+ }
27
+ }
@@ -58,7 +58,7 @@ export const badgeItemVariants = cva({
58
58
  })
59
59
 
60
60
  export const progressBarVariants = cva({
61
- base: 'h-2 w-full overflow-hidden rounded-full bg-secondary',
61
+ base: 'w-full overflow-hidden rounded-full bg-secondary',
62
62
  variants: {
63
63
  size: {
64
64
  sm: 'h-1',
@@ -8,6 +8,8 @@ export {
8
8
  export {
9
9
  selectTriggerVariants,
10
10
  selectContentVariants,
11
+ selectRootClass,
12
+ selectValueClass,
11
13
  selectItemVariants,
12
14
  selectTokens,
13
15
  } from './select.styles.js'
@@ -12,7 +12,7 @@ export const selectTokens: TokenContract = {
12
12
  }
13
13
 
14
14
  export const selectTriggerVariants = cva({
15
- base: 'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
15
+ base: 'flex w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
16
16
  variants: {
17
17
  size: {
18
18
  sm: 'h-8 text-xs',
@@ -26,10 +26,10 @@ export const selectTriggerVariants = cva({
26
26
  })
27
27
 
28
28
  export const selectContentVariants = cva({
29
- base: 'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95',
29
+ base: 'absolute left-0 top-full z-50 mt-1 max-h-96 w-full min-w-[8rem] overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95',
30
30
  variants: {
31
31
  position: {
32
- popper: 'translate-y-1',
32
+ popper: '',
33
33
  'item-aligned': '',
34
34
  },
35
35
  },
@@ -50,3 +50,9 @@ export const selectItemVariants = cva({
50
50
  selected: 'false',
51
51
  },
52
52
  })
53
+
54
+ /** Positioning context: the listbox floats under the trigger. */
55
+ export const selectRootClass = 'relative'
56
+
57
+ /** The selected label inside the trigger; truncates, dims the placeholder. */
58
+ export const selectValueClass = 'truncate text-left data-[placeholder]:text-muted-foreground'
@@ -14,6 +14,12 @@ export interface SelectProps {
14
14
  disabled?: boolean
15
15
  open?: boolean
16
16
  name?: string
17
+ /**
18
+ * Element ids to link trigger and listbox. Adapters should pass ids that
19
+ * are stable across renders (e.g. React `useId`); when omitted, fresh ids
20
+ * are generated on every call.
21
+ */
22
+ ids?: { trigger: string; content: string }
17
23
  }
18
24
 
19
25
  export interface SelectAPI {
@@ -63,14 +69,16 @@ export function createSelect(props: SelectProps = {}): SelectAPI {
63
69
  const isInteractive = !disabled
64
70
  const selectedOption = options.find((o) => o.value === value)
65
71
 
66
- const triggerId = generateId('select-trigger')
67
- const contentId = generateId('select-content')
72
+ const triggerId = props.ids?.trigger ?? generateId('select-trigger')
73
+ const contentId = props.ids?.content ?? generateId('select-content')
68
74
 
75
+ // No aria-labelledby on the trigger: pointing a combobox at itself would
76
+ // override a caller's aria-label / <label for> and leave the accessible
77
+ // name as the displayed value.
69
78
  const triggerAriaProps: Partial<AccessibilityProps> = {
70
79
  role: 'combobox',
71
80
  'aria-expanded': open,
72
81
  'aria-controls': contentId,
73
- 'aria-labelledby': triggerId,
74
82
  }
75
83
  if (disabled) {
76
84
  triggerAriaProps['aria-disabled'] = true
@@ -8,5 +8,7 @@ export {
8
8
  export {
9
9
  separatorVariants,
10
10
  separatorLineClass,
11
+ separatorSubtleLineClass,
11
12
  separatorLabelClass,
13
+ type SeparatorTone,
12
14
  } from './separator.styles.js'
@@ -12,8 +12,16 @@ export const separatorVariants = cva({
12
12
  base: '',
13
13
  variants: {
14
14
  orientation: {
15
- horizontal: 'h-px w-full bg-border',
16
- vertical: 'w-px h-full bg-border',
15
+ horizontal: 'h-px w-full',
16
+ vertical: 'w-px h-full',
17
+ },
18
+ // Line color (issue #485). `default` is the standard border token;
19
+ // `subtle` uses the hairline `border-subtle` token for a lighter rule.
20
+ // Keeping the color here (not in `orientation`) keeps every existing call
21
+ // pixel-identical while adding the subtle option.
22
+ tone: {
23
+ default: 'bg-border',
24
+ subtle: 'bg-border-subtle',
17
25
  },
18
26
  labeled: {
19
27
  true: 'flex items-center',
@@ -22,13 +30,20 @@ export const separatorVariants = cva({
22
30
  },
23
31
  defaultVariants: {
24
32
  orientation: 'horizontal',
33
+ tone: 'default',
25
34
  labeled: 'false',
26
35
  },
27
36
  })
28
37
 
38
+ /** Line tone for the separator rule and labeled-divider flanks. */
39
+ export type SeparatorTone = 'default' | 'subtle'
40
+
29
41
  /** Class for each flanking line in the labeled-divider variant. */
30
42
  export const separatorLineClass = 'h-px flex-1 bg-border'
31
43
 
44
+ /** Hairline variant of {@link separatorLineClass} (issue #485). */
45
+ export const separatorSubtleLineClass = 'h-px flex-1 bg-border-subtle'
46
+
32
47
  /** Class for the centered label in the labeled-divider variant. */
33
48
  export const separatorLabelClass =
34
49
  'px-3 text-xs uppercase tracking-wide text-muted-foreground'
package/dist/shared/cn.ts CHANGED
@@ -3,9 +3,12 @@
3
3
  * Handles conditional classes, arrays, and falsy values.
4
4
  * No external dependencies (no clsx, no tailwind-merge).
5
5
  *
6
- * For Tailwind class conflict resolution (e.g., 'p-2 p-4' → 'p-4'),
7
- * consumers can use @refraction-ui/tailwind-config which provides
8
- * a tw-merge-aware variant of this function.
6
+ * It does NOT resolve Tailwind conflicts: `cn('p-2', 'p-4')` keeps both and
7
+ * the stylesheet order decides. Components therefore keep size-owned
8
+ * utilities (height, text size, padding) out of their cva `base` and only in
9
+ * the matching variant, so a variant prop always wins; a consumer `className`
10
+ * that must beat a component utility can use Tailwind's `!` important
11
+ * modifier (e.g. `!h-7`).
9
12
  */
10
13
 
11
14
  type ClassValue = string | number | boolean | undefined | null | ClassValue[]
@@ -45,6 +45,8 @@ export { slideViewerProgressBarVariants as progressBarVariants }
45
45
  export const slideTypeBadgeVariants = cva({
46
46
  base: 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
47
47
  variants: {
48
+ // Slide-type accents are a content-category palette (lesson/quiz/exercise…),
49
+ // not status tones — deliberately not routed through the theme tokens.
48
50
  type: {
49
51
  lesson: 'bg-blue-100 text-blue-800',
50
52
  quiz: 'bg-purple-100 text-purple-800',
@@ -1,12 +1,21 @@
1
1
  export {
2
2
  createStatGrid,
3
3
  statColumns,
4
+ statColumnsKey,
4
5
  type StatItem,
6
+ type StatTone,
7
+ type StatGridVariant,
8
+ type StatGridLayout,
9
+ type StatGridColumns,
5
10
  type StatGridAPI,
6
11
  } from './stat-grid.js'
7
12
 
8
13
  export {
9
14
  statGridVariants,
15
+ statItemVariants,
16
+ statValueVariants,
17
+ statLabelVariants,
18
+ statDescriptionClass,
10
19
  statItemClass,
11
20
  statValueClass,
12
21
  statLabelClass,
@@ -1,25 +1,92 @@
1
1
  import { cva } from '../shared/index.ts'
2
2
 
3
- /** Grid container that lays out stat items. */
3
+ /**
4
+ * Grid container. Column counts are responsive (one column on a phone, more
5
+ * as the width allows) — never a fixed inline template that cannot reflow.
6
+ * `auto` fits as many ~12rem items per row as the width allows.
7
+ */
4
8
  export const statGridVariants = cva({
5
- base: 'grid gap-8',
9
+ base: 'grid',
6
10
  variants: {
7
11
  columns: {
8
12
  '1': 'grid-cols-1',
9
- '2': 'grid-cols-2',
10
- '3': 'grid-cols-3',
13
+ '2': 'grid-cols-1 sm:grid-cols-2',
14
+ '3': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
15
+ '4': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
16
+ auto: 'grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]',
17
+ },
18
+ variant: {
19
+ plain: 'gap-8',
20
+ card: 'gap-3',
11
21
  },
12
22
  },
13
23
  defaultVariants: {
14
24
  columns: '3',
25
+ variant: 'plain',
15
26
  },
16
27
  })
17
28
 
18
29
  /** Individual stat item container (role="listitem"). */
30
+ export const statItemVariants = cva({
31
+ base: 'flex min-w-0 flex-col',
32
+ variants: {
33
+ variant: {
34
+ plain: '',
35
+ card: 'gap-1 rounded-lg border border-border bg-card p-4 text-card-foreground',
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ variant: 'plain',
40
+ },
41
+ })
42
+
43
+ /** @deprecated Use `statItemVariants()`; kept for existing imports. */
19
44
  export const statItemClass = 'flex flex-col'
20
45
 
21
- /** The large bold value of the stat. */
46
+ /** The value of a stat, by variant and tone. */
47
+ export const statValueVariants = cva({
48
+ base: 'font-bold tabular-nums',
49
+ variants: {
50
+ variant: {
51
+ plain: 'text-xl sm:text-2xl',
52
+ card: 'text-2xl leading-tight',
53
+ },
54
+ tone: {
55
+ default: '',
56
+ positive: 'text-success',
57
+ negative: 'text-destructive',
58
+ caution: 'text-warning',
59
+ },
60
+ },
61
+ compoundVariants: [
62
+ { variant: 'plain', tone: 'default', class: 'text-primary' },
63
+ { variant: 'card', tone: 'default', class: 'text-foreground' },
64
+ ],
65
+ defaultVariants: {
66
+ variant: 'plain',
67
+ tone: 'default',
68
+ },
69
+ })
70
+
71
+ /** @deprecated Use `statValueVariants()`; kept for existing imports. */
22
72
  export const statValueClass = 'text-xl sm:text-2xl font-bold text-primary'
23
73
 
24
- /** The descriptive label below the value. */
74
+ /** The descriptive label. Above the value it reads as a small caption. */
75
+ export const statLabelVariants = cva({
76
+ base: 'text-muted-foreground',
77
+ variants: {
78
+ layout: {
79
+ 'value-first': 'mt-1 text-sm leading-snug',
80
+ 'label-first': 'text-xs uppercase tracking-wide',
81
+ },
82
+ },
83
+ defaultVariants: {
84
+ layout: 'value-first',
85
+ },
86
+ })
87
+
88
+ /** @deprecated Use `statLabelVariants()`; kept for existing imports. */
25
89
  export const statLabelClass = 'mt-1 text-sm text-muted-foreground leading-snug'
90
+
91
+ /** Optional line under a stat explaining how it was computed. */
92
+ export const statDescriptionClass = 'text-xs leading-snug text-muted-foreground'
@@ -1,9 +1,24 @@
1
+ /** Semantic colour of a stat's value. */
2
+ export type StatTone = 'default' | 'positive' | 'negative' | 'caution'
3
+ /** `plain` = marketing callouts; `card` = bordered dashboard KPI cards. */
4
+ export type StatGridVariant = 'plain' | 'card'
5
+ /** Whether the label sits under the value (callout) or above it (KPI). */
6
+ export type StatGridLayout = 'value-first' | 'label-first'
7
+ /** Column count: a fixed maximum that reflows down on narrow screens, or `auto`. */
8
+ export type StatGridColumns = 1 | 2 | 3 | 4 | 'auto'
9
+
1
10
  /** A single stat callout displayed in the grid. */
2
11
  export type StatItem = {
3
12
  /** The primary value displayed prominently (e.g. "10k+", "$4.2M"). */
4
13
  value: string
5
- /** The descriptive label shown below the value. */
14
+ /** The descriptive label. */
6
15
  label: string
16
+ /** Stable key; defaults to the index. */
17
+ id?: string
18
+ /** Optional line explaining the figure (how it was computed, a delta). */
19
+ description?: string
20
+ /** Colour of the value. Defaults to `default`. */
21
+ tone?: StatTone
7
22
  }
8
23
 
9
24
  export interface StatGridAPI {
@@ -20,11 +35,16 @@ export interface StatGridAPI {
20
35
  * - 2 items → 2 columns
21
36
  * - 3 or more items → capped at `max` (default 3)
22
37
  */
23
- export function statColumns(count: number, max = 3): number {
24
- if (count <= 0) return 1
25
- if (count === 1) return 1
38
+ export function statColumns(count: number, max = 3): 1 | 2 | 3 | 4 {
39
+ if (count <= 1) return 1
26
40
  if (count === 2) return 2
27
- return Math.min(count, max)
41
+ return Math.min(count, max, 4) as 3 | 4
42
+ }
43
+
44
+ /** The cva key for a column setting (clamped to the supported 1–4). */
45
+ export function statColumnsKey(columns: StatGridColumns): '1' | '2' | '3' | '4' | 'auto' {
46
+ if (columns === 'auto') return 'auto'
47
+ return String(Math.min(Math.max(Math.round(columns), 1), 4)) as '1' | '2' | '3' | '4'
28
48
  }
29
49
 
30
50
  /**