@dowel-ui/react 0.1.0 → 0.2.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 (195) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +187 -0
  3. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts +77 -0
  4. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts.map +1 -0
  5. package/dist/components/ai-action-ledger/ai-action-ledger.js +224 -0
  6. package/dist/components/ai-action-ledger/ai-action-ledger.js.map +1 -0
  7. package/dist/components/ai-action-ledger/index.d.ts +2 -0
  8. package/dist/components/ai-action-ledger/index.js +2 -0
  9. package/dist/components/ai-action-ledger/meta.js +17 -0
  10. package/dist/components/ai-action-ledger/meta.js.map +1 -0
  11. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  12. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts +45 -0
  13. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
  14. package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
  15. package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
  16. package/dist/components/ai-inline-completion/index.d.ts +2 -0
  17. package/dist/components/ai-inline-completion/index.js +2 -0
  18. package/dist/components/ai-inline-completion/meta.js +17 -0
  19. package/dist/components/ai-inline-completion/meta.js.map +1 -0
  20. package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
  21. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
  22. package/dist/components/ai-structured-output/ai-structured-output.js +126 -0
  23. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
  24. package/dist/components/ai-structured-output/index.d.ts +2 -0
  25. package/dist/components/ai-structured-output/index.js +2 -0
  26. package/dist/components/ai-structured-output/meta.js +17 -0
  27. package/dist/components/ai-structured-output/meta.js.map +1 -0
  28. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  29. package/dist/components/alert/alert.d.ts +1 -1
  30. package/dist/components/avatar/avatar.d.ts +1 -1
  31. package/dist/components/badge/badge.d.ts +1 -1
  32. package/dist/components/button/button.d.ts +1 -1
  33. package/dist/components/meter/index.d.ts +2 -0
  34. package/dist/components/meter/index.js +2 -0
  35. package/dist/components/meter/meta.js +17 -0
  36. package/dist/components/meter/meta.js.map +1 -0
  37. package/dist/components/meter/meter.d.ts +69 -0
  38. package/dist/components/meter/meter.d.ts.map +1 -0
  39. package/dist/components/meter/meter.js +144 -0
  40. package/dist/components/meter/meter.js.map +1 -0
  41. package/dist/components/metric-delta/index.d.ts +2 -0
  42. package/dist/components/metric-delta/index.js +2 -0
  43. package/dist/components/metric-delta/meta.js +17 -0
  44. package/dist/components/metric-delta/meta.js.map +1 -0
  45. package/dist/components/metric-delta/metric-delta.d.ts +59 -0
  46. package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
  47. package/dist/components/metric-delta/metric-delta.js +82 -0
  48. package/dist/components/metric-delta/metric-delta.js.map +1 -0
  49. package/dist/components/progress/progress.d.ts +1 -1
  50. package/dist/components/record-diff/index.d.ts +2 -0
  51. package/dist/components/record-diff/index.js +2 -0
  52. package/dist/components/record-diff/meta.js +17 -0
  53. package/dist/components/record-diff/meta.js.map +1 -0
  54. package/dist/components/record-diff/record-diff.d.ts +56 -0
  55. package/dist/components/record-diff/record-diff.d.ts.map +1 -0
  56. package/dist/components/record-diff/record-diff.js +199 -0
  57. package/dist/components/record-diff/record-diff.js.map +1 -0
  58. package/dist/components/spinner/spinner.d.ts +1 -1
  59. package/dist/components/toast/toast.d.ts +1 -1
  60. package/dist/index.d.ts +13 -1
  61. package/dist/index.js +7 -1
  62. package/dist/registry/components.d.ts.map +1 -1
  63. package/dist/registry/components.js +57 -45
  64. package/dist/registry/components.js.map +1 -1
  65. package/package.json +40 -5
  66. package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
  67. package/src/components/ai-action-ledger/index.ts +14 -0
  68. package/src/components/ai-action-ledger/meta.ts +19 -0
  69. package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
  70. package/src/components/ai-inline-completion/index.ts +1 -0
  71. package/src/components/ai-inline-completion/meta.ts +20 -0
  72. package/src/components/ai-structured-output/ai-structured-output.tsx +244 -0
  73. package/src/components/ai-structured-output/index.ts +10 -0
  74. package/src/components/ai-structured-output/meta.ts +18 -0
  75. package/src/components/meter/index.ts +8 -0
  76. package/src/components/meter/meta.ts +20 -0
  77. package/src/components/meter/meter.tsx +219 -0
  78. package/src/components/metric-delta/index.ts +6 -0
  79. package/src/components/metric-delta/meta.ts +18 -0
  80. package/src/components/metric-delta/metric-delta.tsx +180 -0
  81. package/src/components/record-diff/index.ts +10 -0
  82. package/src/components/record-diff/meta.ts +19 -0
  83. package/src/components/record-diff/record-diff.tsx +341 -0
  84. package/src/index.ts +6 -0
  85. package/src/registry/components.ts +12 -0
  86. package/src/blocks/admin-users/admin-users.stories.tsx +0 -83
  87. package/src/blocks/admin-users/admin-users.test.tsx +0 -159
  88. package/src/blocks/ai-chat/ai-chat.stories.tsx +0 -149
  89. package/src/blocks/ai-chat/ai-chat.test.tsx +0 -166
  90. package/src/blocks/dashboard/dashboard.stories.tsx +0 -106
  91. package/src/blocks/dashboard/dashboard.test.tsx +0 -94
  92. package/src/blocks/forgot-password/forgot-password.stories.tsx +0 -41
  93. package/src/blocks/forgot-password/forgot-password.test.tsx +0 -62
  94. package/src/blocks/login/login.stories.tsx +0 -46
  95. package/src/blocks/login/login.test.tsx +0 -100
  96. package/src/blocks/pricing/pricing.stories.tsx +0 -62
  97. package/src/blocks/pricing/pricing.test.tsx +0 -101
  98. package/src/blocks/settings/settings.stories.tsx +0 -94
  99. package/src/blocks/settings/settings.test.tsx +0 -167
  100. package/src/blocks/signup/signup.stories.tsx +0 -27
  101. package/src/blocks/signup/signup.test.tsx +0 -97
  102. package/src/components/accordion/accordion.stories.tsx +0 -90
  103. package/src/components/accordion/accordion.test.tsx +0 -156
  104. package/src/components/activity-feed/activity-feed.stories.tsx +0 -163
  105. package/src/components/activity-feed/activity-feed.test.tsx +0 -125
  106. package/src/components/ai-agent-status/ai-agent-status.stories.tsx +0 -86
  107. package/src/components/ai-agent-status/ai-agent-status.test.tsx +0 -66
  108. package/src/components/ai-conversation/ai-conversation.stories.tsx +0 -126
  109. package/src/components/ai-conversation/ai-conversation.test.tsx +0 -144
  110. package/src/components/ai-message/ai-message.stories.tsx +0 -106
  111. package/src/components/ai-message/ai-message.test.tsx +0 -160
  112. package/src/components/ai-model-selector/ai-model-selector.stories.tsx +0 -66
  113. package/src/components/ai-model-selector/ai-model-selector.test.tsx +0 -120
  114. package/src/components/ai-prompt-input/ai-prompt-input.stories.tsx +0 -145
  115. package/src/components/ai-prompt-input/ai-prompt-input.test.tsx +0 -273
  116. package/src/components/ai-reasoning/ai-reasoning.stories.tsx +0 -60
  117. package/src/components/ai-reasoning/ai-reasoning.test.tsx +0 -95
  118. package/src/components/ai-response/ai-response.stories.tsx +0 -75
  119. package/src/components/ai-response/ai-response.test.tsx +0 -96
  120. package/src/components/ai-sources/ai-sources.stories.tsx +0 -93
  121. package/src/components/ai-sources/ai-sources.test.tsx +0 -102
  122. package/src/components/ai-token-usage/ai-token-usage.stories.tsx +0 -59
  123. package/src/components/ai-token-usage/ai-token-usage.test.tsx +0 -83
  124. package/src/components/ai-tool/ai-tool.stories.tsx +0 -96
  125. package/src/components/ai-tool/ai-tool.test.tsx +0 -137
  126. package/src/components/alert/alert.stories.tsx +0 -101
  127. package/src/components/alert/alert.test.tsx +0 -94
  128. package/src/components/avatar/avatar.stories.tsx +0 -58
  129. package/src/components/avatar/avatar.test.tsx +0 -64
  130. package/src/components/badge/badge.stories.tsx +0 -68
  131. package/src/components/badge/badge.test.tsx +0 -61
  132. package/src/components/button/button.stories.tsx +0 -128
  133. package/src/components/button/button.test.tsx +0 -224
  134. package/src/components/calendar/calendar.stories.tsx +0 -105
  135. package/src/components/calendar/calendar.test.tsx +0 -255
  136. package/src/components/card/card.stories.tsx +0 -61
  137. package/src/components/card/card.test.tsx +0 -66
  138. package/src/components/checkbox/checkbox.stories.tsx +0 -124
  139. package/src/components/checkbox/checkbox.test.tsx +0 -121
  140. package/src/components/code-block/code-block.stories.tsx +0 -62
  141. package/src/components/code-block/code-block.test.tsx +0 -139
  142. package/src/components/combobox/combobox.stories.tsx +0 -171
  143. package/src/components/combobox/combobox.test.tsx +0 -429
  144. package/src/components/command/command.stories.tsx +0 -152
  145. package/src/components/command/command.test.tsx +0 -380
  146. package/src/components/data-table/data-table.stories.tsx +0 -151
  147. package/src/components/data-table/data-table.test.tsx +0 -288
  148. package/src/components/date-picker/date-picker.stories.tsx +0 -103
  149. package/src/components/date-picker/date-picker.test.tsx +0 -205
  150. package/src/components/dialog/dialog.stories.tsx +0 -140
  151. package/src/components/dialog/dialog.test.tsx +0 -186
  152. package/src/components/drawer/drawer.stories.tsx +0 -111
  153. package/src/components/drawer/drawer.test.tsx +0 -187
  154. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +0 -128
  155. package/src/components/dropdown-menu/dropdown-menu.test.tsx +0 -321
  156. package/src/components/empty-state/empty-state.stories.tsx +0 -70
  157. package/src/components/empty-state/empty-state.test.tsx +0 -97
  158. package/src/components/form/form.stories.tsx +0 -144
  159. package/src/components/form/form.test.tsx +0 -203
  160. package/src/components/input/input.stories.tsx +0 -84
  161. package/src/components/input/input.test.tsx +0 -108
  162. package/src/components/label/label.stories.tsx +0 -51
  163. package/src/components/label/label.test.tsx +0 -55
  164. package/src/components/pagination/pagination.stories.tsx +0 -129
  165. package/src/components/pagination/pagination.test.tsx +0 -119
  166. package/src/components/popover/popover.stories.tsx +0 -84
  167. package/src/components/popover/popover.test.tsx +0 -163
  168. package/src/components/progress/progress.stories.tsx +0 -90
  169. package/src/components/progress/progress.test.tsx +0 -78
  170. package/src/components/radio-group/radio-group.stories.tsx +0 -102
  171. package/src/components/radio-group/radio-group.test.tsx +0 -152
  172. package/src/components/select/select.stories.tsx +0 -137
  173. package/src/components/select/select.test.tsx +0 -209
  174. package/src/components/separator/separator.stories.tsx +0 -42
  175. package/src/components/separator/separator.test.tsx +0 -46
  176. package/src/components/sheet/sheet.stories.tsx +0 -107
  177. package/src/components/sheet/sheet.test.tsx +0 -152
  178. package/src/components/skeleton/skeleton.stories.tsx +0 -46
  179. package/src/components/skeleton/skeleton.test.tsx +0 -57
  180. package/src/components/slider/slider.stories.tsx +0 -87
  181. package/src/components/slider/slider.test.tsx +0 -148
  182. package/src/components/spinner/spinner.stories.tsx +0 -47
  183. package/src/components/spinner/spinner.test.tsx +0 -41
  184. package/src/components/switch/switch.stories.tsx +0 -70
  185. package/src/components/switch/switch.test.tsx +0 -101
  186. package/src/components/table/table.stories.tsx +0 -131
  187. package/src/components/table/table.test.tsx +0 -153
  188. package/src/components/tabs/tabs.stories.tsx +0 -139
  189. package/src/components/tabs/tabs.test.tsx +0 -148
  190. package/src/components/toast/toast.stories.tsx +0 -167
  191. package/src/components/toast/toast.test.tsx +0 -306
  192. package/src/components/tooltip/tooltip.stories.tsx +0 -89
  193. package/src/components/tooltip/tooltip.test.tsx +0 -120
  194. package/src/lib/utils.test.ts +0 -32
  195. package/src/registry/meta.test.ts +0 -234
@@ -0,0 +1,180 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import type { ComponentPropsWithRef } from "react";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ /**
9
+ * A headline number with an honest change indicator.
10
+ *
11
+ * The visual is a commodity; the correctness is not. Three things go wrong in
12
+ * almost every hand-rolled version, and all three are handled here:
13
+ *
14
+ * 1. Direction is assumed to be good news. Rising churn, rising latency and
15
+ * rising cost are all bad, and painting them green is worse than showing no
16
+ * colour at all. `polarity` decides.
17
+ * 2. A zero baseline produces `+∞%` or `NaN`. There is no percentage change
18
+ * from nothing, so this says so in words instead of inventing one.
19
+ * 3. Meaning is carried by colour and an arrow alone, which fails WCAG 1.4.1
20
+ * and is unreadable to anyone who does not already know the convention. The
21
+ * direction is always stated in text.
22
+ *
23
+ * What this deliberately does NOT do is claim statistical significance. A
24
+ * component holding two numbers cannot know whether a change is significant —
25
+ * that needs the sample size and a test. `insignificantBelow` is a presentation
26
+ * threshold and is described as one; passing `sampleSize` only adds it to the
27
+ * accessible description, it does not license a stronger claim.
28
+ */
29
+
30
+ export type MetricPolarity = "higher-is-better" | "lower-is-better" | "neutral";
31
+
32
+ const deltaVariants = cva("inline-flex items-center gap-1 text-xs font-medium tabular-nums", {
33
+ variants: {
34
+ sentiment: {
35
+ good: "text-success",
36
+ bad: "text-destructive",
37
+ neutral: "text-muted-foreground",
38
+ },
39
+ },
40
+ defaultVariants: { sentiment: "neutral" },
41
+ });
42
+
43
+ /** What the change means, once polarity is applied. */
44
+ function sentimentOf(
45
+ change: number,
46
+ polarity: MetricPolarity,
47
+ meaningful: boolean,
48
+ ): "good" | "bad" | "neutral" {
49
+ if (!meaningful || change === 0 || polarity === "neutral") return "neutral";
50
+ const improved = polarity === "higher-is-better" ? change > 0 : change < 0;
51
+ return improved ? "good" : "bad";
52
+ }
53
+
54
+ export interface MetricDeltaProps
55
+ extends Omit<ComponentPropsWithRef<"div">, "children">, VariantProps<typeof deltaVariants> {
56
+ /** The current value. */
57
+ value: number;
58
+ /** The value being compared against. Omit when there is nothing to compare. */
59
+ previous?: number;
60
+ /** Names the metric. */
61
+ label: string;
62
+ /**
63
+ * Whether a rise is good news. Churn, latency, error rate and cost are all
64
+ * `lower-is-better`, and getting this wrong paints bad news green.
65
+ */
66
+ polarity?: MetricPolarity;
67
+ /**
68
+ * Relative change below which the delta is shown without sentiment, as a
69
+ * fraction. Presentation only — this is not a significance test.
70
+ */
71
+ insignificantBelow?: number;
72
+ /**
73
+ * Included in the accessible description when given, so a reader can judge
74
+ * the change for themselves. Never used to compute significance.
75
+ */
76
+ sampleSize?: number;
77
+ /** What the previous value represents, e.g. "vs last week". */
78
+ comparisonLabel?: string;
79
+ /** Formats the headline value. */
80
+ format?: (value: number) => string;
81
+ }
82
+
83
+ function defaultFormat(value: number): string {
84
+ return new Intl.NumberFormat().format(value);
85
+ }
86
+
87
+ const percentFormatter = new Intl.NumberFormat(undefined, {
88
+ style: "percent",
89
+ maximumFractionDigits: 1,
90
+ signDisplay: "exceptZero",
91
+ });
92
+
93
+ export function MetricDelta({
94
+ className,
95
+ value,
96
+ previous,
97
+ label,
98
+ polarity = "higher-is-better",
99
+ insignificantBelow = 0,
100
+ sampleSize,
101
+ comparisonLabel = "vs previous period",
102
+ format = defaultFormat,
103
+ ...props
104
+ }: MetricDeltaProps) {
105
+ const hasComparison = previous !== undefined;
106
+ const absoluteChange = hasComparison ? value - previous : 0;
107
+
108
+ // A percentage change from zero is undefined, not infinite. Reporting it as
109
+ // a number at all is the lie; the component says "no prior value" instead.
110
+ const canComputeRelative = hasComparison && previous !== 0;
111
+ const relativeChange = canComputeRelative ? absoluteChange / Math.abs(previous) : null;
112
+
113
+ const meaningful =
114
+ relativeChange === null
115
+ ? absoluteChange !== 0
116
+ : Math.abs(relativeChange) >= insignificantBelow;
117
+
118
+ const sentiment = hasComparison
119
+ ? sentimentOf(absoluteChange, polarity, meaningful)
120
+ : "neutral";
121
+
122
+ const direction = absoluteChange > 0 ? "up" : absoluteChange < 0 ? "down" : "flat";
123
+
124
+ const changeText = !hasComparison
125
+ ? null
126
+ : relativeChange !== null
127
+ ? percentFormatter.format(relativeChange)
128
+ : `${absoluteChange > 0 ? "+" : ""}${format(absoluteChange)}`;
129
+
130
+ // Everything a screen reader needs, in one sentence, in the order it matters.
131
+ const description = [
132
+ `${label}: ${format(value)}`,
133
+ hasComparison
134
+ ? relativeChange !== null
135
+ ? `${direction === "flat" ? "unchanged" : direction} ${changeText ?? ""} ${comparisonLabel}`
136
+ : `${direction === "flat" ? "unchanged" : direction} by ${format(Math.abs(absoluteChange))} ${comparisonLabel}, no percentage available from a zero baseline`
137
+ : null,
138
+ hasComparison && !meaningful && direction !== "flat" ? "not a meaningful change" : null,
139
+ sampleSize !== undefined ? `sample size ${format(sampleSize)}` : null,
140
+ ]
141
+ .filter(Boolean)
142
+ .join(", ");
143
+
144
+ return (
145
+ <div
146
+ data-slot="metric-delta"
147
+ data-direction={direction}
148
+ data-sentiment={sentiment}
149
+ className={cn("flex flex-col gap-1", className)}
150
+ {...props}
151
+ >
152
+ <p className="text-xs text-muted-foreground">{label}</p>
153
+
154
+ {/* One accessible sentence for the whole tile. The parts below are hidden
155
+ so the reader gets the meaning once, not as three fragments. */}
156
+ <p className="sr-only">{description}</p>
157
+
158
+ <p
159
+ aria-hidden="true"
160
+ className="text-2xl leading-tight font-semibold tracking-tight tabular-nums"
161
+ >
162
+ {format(value)}
163
+ </p>
164
+
165
+ {hasComparison ? (
166
+ <p aria-hidden="true" className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
167
+ <span className={cn(deltaVariants({ sentiment }))} data-slot="metric-delta-change">
168
+ <span aria-hidden="true">
169
+ {direction === "up" ? "↑" : direction === "down" ? "↓" : "→"}
170
+ </span>
171
+ {changeText}
172
+ </span>
173
+ <span className="text-xs text-muted-foreground">{comparisonLabel}</span>
174
+ </p>
175
+ ) : null}
176
+ </div>
177
+ );
178
+ }
179
+
180
+ export { deltaVariants };
@@ -0,0 +1,10 @@
1
+ export {
2
+ RecordDiff,
3
+ RecordDiffSummary,
4
+ RecordDiffTable,
5
+ diffRecords,
6
+ type FieldChange,
7
+ type RecordDiffProps,
8
+ type RecordDiffTableProps,
9
+ type RecordField,
10
+ } from "./record-diff";
@@ -0,0 +1,19 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "record-diff",
5
+ title: "Record Diff",
6
+ description:
7
+ "Field-level before and after, for audit entries, settings history and revisions.",
8
+ category: "data",
9
+ status: "stable",
10
+ dependencies: [],
11
+ registryDependencies: [],
12
+ files: ["record-diff.tsx"],
13
+ a11y:
14
+ 'A real table, not a grid of divs. Each field name is a th with scope="row", so a value is ' +
15
+ "announced with the field it belongs to rather than as a loose cell. The kind of change is " +
16
+ "never carried by row colour alone — every row states added, removed or changed in text for " +
17
+ "screen readers, satisfying WCAG 1.4.1. The unchanged-field toggle is a real button with " +
18
+ "aria-expanded. Redacted values never reach the DOM at all.",
19
+ });
@@ -0,0 +1,341 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useMemo,
7
+ useState,
8
+ type ComponentPropsWithRef,
9
+ } from "react";
10
+
11
+ import { cn } from "@/lib/utils";
12
+
13
+ /**
14
+ * What changed about a record, field by field.
15
+ *
16
+ * Every diff package on npm compares lines of text. That is the wrong shape for
17
+ * an audit entry, a settings revision or a config history, where the change is
18
+ * "role went from viewer to admin" and rendering it as two lines of JSON makes
19
+ * the reader do the comparison themselves.
20
+ *
21
+ * Scope is deliberately flat records. Nested objects and arrays are formatted as
22
+ * values rather than recursed into, because a recursive differ is a different
23
+ * and much larger component, and pretending otherwise would produce a shallow
24
+ * one that quietly lies about deep changes. `formatValue` is the escape hatch.
25
+ *
26
+ * Redaction is by key pattern and applied before formatting, so a secret is
27
+ * never handed to a formatter that might log it.
28
+ */
29
+
30
+ export type FieldChange = "added" | "removed" | "changed" | "unchanged";
31
+
32
+ export interface RecordField {
33
+ key: string;
34
+ label: string;
35
+ change: FieldChange;
36
+ before: unknown;
37
+ after: unknown;
38
+ redacted: boolean;
39
+ }
40
+
41
+ /** Values that read as absent, so "" → null is not reported as a change. */
42
+ function isEmpty(value: unknown): boolean {
43
+ return value === null || value === undefined || value === "";
44
+ }
45
+
46
+ function classify(before: unknown, after: unknown): FieldChange {
47
+ const hadBefore = !isEmpty(before);
48
+ const hasAfter = !isEmpty(after);
49
+
50
+ if (!hadBefore && hasAfter) return "added";
51
+ if (hadBefore && !hasAfter) return "removed";
52
+ // Both absent. Falling through would compare "" against null, which
53
+ // serialise differently and would report a change that did not happen.
54
+ if (!hadBefore && !hasAfter) return "unchanged";
55
+ // Structural comparison, so {a:1} and {a:1} are equal. Key order differences
56
+ // would report a false change, so keys are sorted before comparing.
57
+ return stableStringify(before) === stableStringify(after) ? "unchanged" : "changed";
58
+ }
59
+
60
+ function stableStringify(value: unknown): string {
61
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "";
62
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
63
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
64
+ a.localeCompare(b),
65
+ );
66
+ return `{${entries.map(([k, v]) => `${k}:${stableStringify(v)}`).join(",")}}`;
67
+ }
68
+
69
+ function defaultFormatValue(value: unknown): string {
70
+ if (value === null || value === undefined) return "—";
71
+ if (value === "") return "empty";
72
+ if (typeof value === "string") return value;
73
+ if (typeof value === "boolean") return value ? "yes" : "no";
74
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
75
+ if (Array.isArray(value)) {
76
+ return value.length === 0 ? "none" : value.map(defaultFormatValue).join(", ");
77
+ }
78
+ if (typeof value === "object") return JSON.stringify(value);
79
+ // Symbols and functions have no sensible representation as a field value, and
80
+ // String() would yield "[object Object]" or a function body. Say what it is.
81
+ return `[${typeof value}]`;
82
+ }
83
+
84
+ /** Keys whose values must never be rendered, however the record was built. */
85
+ const DEFAULT_REDACT = /password|secret|token|api[-_]?key|authorization|credential/i;
86
+
87
+ export interface RecordDiffProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
88
+ before: Record<string, unknown>;
89
+ after: Record<string, unknown>;
90
+ /** Overrides the humanised key. */
91
+ labels?: Record<string, string>;
92
+ /** Keys to render as redacted. Defaults to common secret-shaped names. */
93
+ redact?: RegExp;
94
+ /** Only these keys, in this order. Anything else is ignored. */
95
+ fields?: string[];
96
+ /** Hides unchanged fields behind a toggle. */
97
+ collapseUnchanged?: boolean;
98
+ formatValue?: (value: unknown, key: string) => string;
99
+ children?: React.ReactNode;
100
+ }
101
+
102
+ interface RecordDiffContextValue {
103
+ fields: RecordField[];
104
+ changedCount: number;
105
+ formatValue: (value: unknown, key: string) => string;
106
+ }
107
+
108
+ const RecordDiffContext = createContext<RecordDiffContextValue | null>(null);
109
+
110
+ function useRecordDiffContext(component: string): RecordDiffContextValue {
111
+ const context = useContext(RecordDiffContext);
112
+ if (!context) {
113
+ throw new Error(`${component} must be rendered inside <RecordDiff>.`);
114
+ }
115
+ return context;
116
+ }
117
+
118
+ /** "billingEmail" and "billing_email" both read as "Billing email". */
119
+ function humanise(key: string): string {
120
+ const spaced = key
121
+ .replace(/[_-]+/g, " ")
122
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
123
+ .trim();
124
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
125
+ }
126
+
127
+ export function diffRecords(
128
+ before: Record<string, unknown>,
129
+ after: Record<string, unknown>,
130
+ options: { labels?: Record<string, string>; redact?: RegExp; fields?: string[] } = {},
131
+ ): RecordField[] {
132
+ const { labels = {}, redact = DEFAULT_REDACT, fields } = options;
133
+
134
+ // The union of both sides, so a removed key is still reported. Iterating only
135
+ // `after` is how removals go missing from audit logs.
136
+ const keys = fields ?? [...new Set([...Object.keys(before), ...Object.keys(after)])];
137
+
138
+ return keys.map((key) => ({
139
+ key,
140
+ label: labels[key] ?? humanise(key),
141
+ change: classify(before[key], after[key]),
142
+ before: before[key],
143
+ after: after[key],
144
+ redacted: redact.test(key),
145
+ }));
146
+ }
147
+
148
+ const CHANGE_STYLES: Record<FieldChange, string> = {
149
+ added: "text-success",
150
+ removed: "text-destructive",
151
+ changed: "text-foreground",
152
+ unchanged: "text-muted-foreground",
153
+ };
154
+
155
+ export function RecordDiff({
156
+ className,
157
+ before,
158
+ after,
159
+ labels,
160
+ redact = DEFAULT_REDACT,
161
+ fields: fieldKeys,
162
+ collapseUnchanged = true,
163
+ formatValue = defaultFormatValue,
164
+ children,
165
+ ...props
166
+ }: RecordDiffProps) {
167
+ const fields = useMemo(
168
+ () => diffRecords(before, after, { labels, redact, fields: fieldKeys }),
169
+ [before, after, labels, redact, fieldKeys],
170
+ );
171
+
172
+ const changedCount = fields.filter((field) => field.change !== "unchanged").length;
173
+
174
+ const context = useMemo<RecordDiffContextValue>(
175
+ () => ({ fields, changedCount, formatValue }),
176
+ [fields, changedCount, formatValue],
177
+ );
178
+
179
+ return (
180
+ <RecordDiffContext.Provider value={context}>
181
+ <div
182
+ data-slot="record-diff"
183
+ data-changed={changedCount}
184
+ className={cn("flex flex-col gap-2", className)}
185
+ {...props}
186
+ >
187
+ {children ?? (
188
+ <>
189
+ <RecordDiffSummary />
190
+ <RecordDiffTable collapseUnchanged={collapseUnchanged} />
191
+ </>
192
+ )}
193
+ </div>
194
+ </RecordDiffContext.Provider>
195
+ );
196
+ }
197
+
198
+ /** "3 fields changed" — the thing a reviewer scanning a log needs first. */
199
+ export function RecordDiffSummary({ className, ...props }: ComponentPropsWithRef<"p">) {
200
+ const { changedCount, fields } = useRecordDiffContext("RecordDiffSummary");
201
+
202
+ return (
203
+ <p
204
+ data-slot="record-diff-summary"
205
+ className={cn("text-xs text-muted-foreground", className)}
206
+ {...props}
207
+ >
208
+ {changedCount === 0
209
+ ? "No fields changed"
210
+ : `${String(changedCount)} of ${String(fields.length)} field${fields.length === 1 ? "" : "s"} changed`}
211
+ </p>
212
+ );
213
+ }
214
+
215
+ export interface RecordDiffTableProps extends ComponentPropsWithRef<"table"> {
216
+ collapseUnchanged?: boolean;
217
+ }
218
+
219
+ export function RecordDiffTable({
220
+ className,
221
+ collapseUnchanged = true,
222
+ ...props
223
+ }: RecordDiffTableProps) {
224
+ const { fields, formatValue, changedCount } = useRecordDiffContext("RecordDiffTable");
225
+ const [showUnchanged, setShowUnchanged] = useState(!collapseUnchanged);
226
+
227
+ const unchangedCount = fields.length - changedCount;
228
+ const visible = showUnchanged
229
+ ? fields
230
+ : fields.filter((field) => field.change !== "unchanged");
231
+
232
+ return (
233
+ <div className="flex flex-col gap-2">
234
+ <div className="overflow-x-auto">
235
+ <table
236
+ data-slot="record-diff-table"
237
+ className={cn("w-full border-collapse text-sm", className)}
238
+ {...props}
239
+ >
240
+ <thead>
241
+ <tr className="border-b border-border">
242
+ <th
243
+ scope="col"
244
+ className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
245
+ >
246
+ Field
247
+ </th>
248
+ <th
249
+ scope="col"
250
+ className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
251
+ >
252
+ Before
253
+ </th>
254
+ <th
255
+ scope="col"
256
+ className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
257
+ >
258
+ After
259
+ </th>
260
+ </tr>
261
+ </thead>
262
+ <tbody>
263
+ {visible.map((field) => (
264
+ <tr
265
+ key={field.key}
266
+ data-slot="record-diff-row"
267
+ data-change={field.change}
268
+ className="border-b border-border last:border-0"
269
+ >
270
+ {/* scope="row" so each value is announced with its field name
271
+ rather than as a loose cell in a wall of table. */}
272
+ <th
273
+ scope="row"
274
+ className="px-3 py-2 text-left align-top font-medium whitespace-nowrap"
275
+ >
276
+ {field.label}
277
+ {/* The change kind must not be carried by row colour alone. */}
278
+ <span className="sr-only">{`, ${field.change}`}</span>
279
+ </th>
280
+ <td className="px-3 py-2 align-top text-muted-foreground">
281
+ {field.redacted ? (
282
+ <RedactedValue />
283
+ ) : field.change === "added" ? (
284
+ <span className="text-muted-foreground">—</span>
285
+ ) : (
286
+ <span className="line-through decoration-muted-foreground/50">
287
+ {formatValue(field.before, field.key)}
288
+ </span>
289
+ )}
290
+ </td>
291
+ <td className={cn("px-3 py-2 align-top", CHANGE_STYLES[field.change])}>
292
+ {field.redacted ? (
293
+ <RedactedValue />
294
+ ) : field.change === "removed" ? (
295
+ <span className="text-muted-foreground">—</span>
296
+ ) : (
297
+ formatValue(field.after, field.key)
298
+ )}
299
+ </td>
300
+ </tr>
301
+ ))}
302
+ </tbody>
303
+ </table>
304
+ </div>
305
+
306
+ {collapseUnchanged && unchangedCount > 0 ? (
307
+ <button
308
+ type="button"
309
+ data-slot="record-diff-toggle"
310
+ aria-expanded={showUnchanged}
311
+ onClick={() => {
312
+ setShowUnchanged((shown) => !shown);
313
+ }}
314
+ className={cn(
315
+ "self-start rounded-md px-1.5 py-0.5 text-xs text-muted-foreground",
316
+ "transition-colors hover:text-foreground",
317
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring/55",
318
+ )}
319
+ >
320
+ {showUnchanged
321
+ ? `Hide ${String(unchangedCount)} unchanged`
322
+ : `Show ${String(unchangedCount)} unchanged`}
323
+ </button>
324
+ ) : null}
325
+ </div>
326
+ );
327
+ }
328
+
329
+ /**
330
+ * A value that is never rendered.
331
+ *
332
+ * Redaction happens before formatting, so a secret is not passed to a formatter
333
+ * that might log or transform it — the value simply does not reach the DOM.
334
+ */
335
+ function RedactedValue() {
336
+ return (
337
+ <span data-slot="record-diff-redacted" className="text-muted-foreground italic">
338
+ redacted
339
+ </span>
340
+ );
341
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from "./components/accordion";
2
2
  export * from "./components/activity-feed";
3
+ export * from "./components/ai-action-ledger";
4
+ export * from "./components/ai-inline-completion";
5
+ export * from "./components/ai-structured-output";
3
6
  export * from "./components/ai-agent-status";
4
7
  export * from "./components/ai-conversation";
5
8
  export * from "./components/ai-message";
@@ -29,6 +32,9 @@ export * from "./components/empty-state";
29
32
  export * from "./components/form";
30
33
  export * from "./components/input";
31
34
  export * from "./components/label";
35
+ export * from "./components/meter";
36
+ export * from "./components/metric-delta";
37
+ export * from "./components/record-diff";
32
38
  export * from "./components/pagination";
33
39
  export * from "./components/popover";
34
40
  export * from "./components/progress";
@@ -2,14 +2,17 @@ import type { ComponentMeta } from "./schema";
2
2
 
3
3
  import { meta as accordionMeta } from "@/components/accordion/meta";
4
4
  import { meta as activityFeedMeta } from "@/components/activity-feed/meta";
5
+ import { meta as aiActionLedgerMeta } from "@/components/ai-action-ledger/meta";
5
6
  import { meta as aiAgentStatusMeta } from "@/components/ai-agent-status/meta";
6
7
  import { meta as aiConversationMeta } from "@/components/ai-conversation/meta";
8
+ import { meta as aiInlineCompletionMeta } from "@/components/ai-inline-completion/meta";
7
9
  import { meta as aiMessageMeta } from "@/components/ai-message/meta";
8
10
  import { meta as aiModelSelectorMeta } from "@/components/ai-model-selector/meta";
9
11
  import { meta as aiPromptInputMeta } from "@/components/ai-prompt-input/meta";
10
12
  import { meta as aiReasoningMeta } from "@/components/ai-reasoning/meta";
11
13
  import { meta as aiResponseMeta } from "@/components/ai-response/meta";
12
14
  import { meta as aiSourcesMeta } from "@/components/ai-sources/meta";
15
+ import { meta as aiStructuredOutputMeta } from "@/components/ai-structured-output/meta";
13
16
  import { meta as aiTokenUsageMeta } from "@/components/ai-token-usage/meta";
14
17
  import { meta as aiToolMeta } from "@/components/ai-tool/meta";
15
18
  import { meta as alertMeta } from "@/components/alert/meta";
@@ -31,10 +34,13 @@ import { meta as emptyStateMeta } from "@/components/empty-state/meta";
31
34
  import { meta as formMeta } from "@/components/form/meta";
32
35
  import { meta as inputMeta } from "@/components/input/meta";
33
36
  import { meta as labelMeta } from "@/components/label/meta";
37
+ import { meta as meterMeta } from "@/components/meter/meta";
38
+ import { meta as metricDeltaMeta } from "@/components/metric-delta/meta";
34
39
  import { meta as paginationMeta } from "@/components/pagination/meta";
35
40
  import { meta as popoverMeta } from "@/components/popover/meta";
36
41
  import { meta as progressMeta } from "@/components/progress/meta";
37
42
  import { meta as radioGroupMeta } from "@/components/radio-group/meta";
43
+ import { meta as recordDiffMeta } from "@/components/record-diff/meta";
38
44
  import { meta as selectMeta } from "@/components/select/meta";
39
45
  import { meta as separatorMeta } from "@/components/separator/meta";
40
46
  import { meta as sheetMeta } from "@/components/sheet/meta";
@@ -61,14 +67,17 @@ import { meta as tooltipMeta } from "@/components/tooltip/meta";
61
67
  export const componentMetas: ComponentMeta[] = [
62
68
  accordionMeta,
63
69
  activityFeedMeta,
70
+ aiActionLedgerMeta,
64
71
  aiAgentStatusMeta,
65
72
  aiConversationMeta,
73
+ aiInlineCompletionMeta,
66
74
  aiMessageMeta,
67
75
  aiModelSelectorMeta,
68
76
  aiPromptInputMeta,
69
77
  aiReasoningMeta,
70
78
  aiResponseMeta,
71
79
  aiSourcesMeta,
80
+ aiStructuredOutputMeta,
72
81
  aiTokenUsageMeta,
73
82
  aiToolMeta,
74
83
  alertMeta,
@@ -90,10 +99,13 @@ export const componentMetas: ComponentMeta[] = [
90
99
  formMeta,
91
100
  inputMeta,
92
101
  labelMeta,
102
+ meterMeta,
103
+ metricDeltaMeta,
93
104
  paginationMeta,
94
105
  popoverMeta,
95
106
  progressMeta,
96
107
  radioGroupMeta,
108
+ recordDiffMeta,
97
109
  selectMeta,
98
110
  separatorMeta,
99
111
  sheetMeta,