@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.
- package/LICENSE +1 -1
- package/README.md +187 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.d.ts +77 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.d.ts.map +1 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.js +224 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.js.map +1 -0
- package/dist/components/ai-action-ledger/index.d.ts +2 -0
- package/dist/components/ai-action-ledger/index.js +2 -0
- package/dist/components/ai-action-ledger/meta.js +17 -0
- package/dist/components/ai-action-ledger/meta.js.map +1 -0
- package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
- package/dist/components/ai-inline-completion/ai-inline-completion.d.ts +45 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
- package/dist/components/ai-inline-completion/index.d.ts +2 -0
- package/dist/components/ai-inline-completion/index.js +2 -0
- package/dist/components/ai-inline-completion/meta.js +17 -0
- package/dist/components/ai-inline-completion/meta.js.map +1 -0
- package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
- package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
- package/dist/components/ai-structured-output/ai-structured-output.js +126 -0
- package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
- package/dist/components/ai-structured-output/index.d.ts +2 -0
- package/dist/components/ai-structured-output/index.js +2 -0
- package/dist/components/ai-structured-output/meta.js +17 -0
- package/dist/components/ai-structured-output/meta.js.map +1 -0
- package/dist/components/ai-tool/ai-tool.d.ts +1 -1
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/meter/index.d.ts +2 -0
- package/dist/components/meter/index.js +2 -0
- package/dist/components/meter/meta.js +17 -0
- package/dist/components/meter/meta.js.map +1 -0
- package/dist/components/meter/meter.d.ts +69 -0
- package/dist/components/meter/meter.d.ts.map +1 -0
- package/dist/components/meter/meter.js +144 -0
- package/dist/components/meter/meter.js.map +1 -0
- package/dist/components/metric-delta/index.d.ts +2 -0
- package/dist/components/metric-delta/index.js +2 -0
- package/dist/components/metric-delta/meta.js +17 -0
- package/dist/components/metric-delta/meta.js.map +1 -0
- package/dist/components/metric-delta/metric-delta.d.ts +59 -0
- package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
- package/dist/components/metric-delta/metric-delta.js +82 -0
- package/dist/components/metric-delta/metric-delta.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/record-diff/index.d.ts +2 -0
- package/dist/components/record-diff/index.js +2 -0
- package/dist/components/record-diff/meta.js +17 -0
- package/dist/components/record-diff/meta.js.map +1 -0
- package/dist/components/record-diff/record-diff.d.ts +56 -0
- package/dist/components/record-diff/record-diff.d.ts.map +1 -0
- package/dist/components/record-diff/record-diff.js +199 -0
- package/dist/components/record-diff/record-diff.js.map +1 -0
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +7 -1
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +57 -45
- package/dist/registry/components.js.map +1 -1
- package/package.json +40 -5
- package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
- package/src/components/ai-action-ledger/index.ts +14 -0
- package/src/components/ai-action-ledger/meta.ts +19 -0
- package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
- package/src/components/ai-inline-completion/index.ts +1 -0
- package/src/components/ai-inline-completion/meta.ts +20 -0
- package/src/components/ai-structured-output/ai-structured-output.tsx +244 -0
- package/src/components/ai-structured-output/index.ts +10 -0
- package/src/components/ai-structured-output/meta.ts +18 -0
- package/src/components/meter/index.ts +8 -0
- package/src/components/meter/meta.ts +20 -0
- package/src/components/meter/meter.tsx +219 -0
- package/src/components/metric-delta/index.ts +6 -0
- package/src/components/metric-delta/meta.ts +18 -0
- package/src/components/metric-delta/metric-delta.tsx +180 -0
- package/src/components/record-diff/index.ts +10 -0
- package/src/components/record-diff/meta.ts +19 -0
- package/src/components/record-diff/record-diff.tsx +341 -0
- package/src/index.ts +6 -0
- package/src/registry/components.ts +12 -0
- package/src/blocks/admin-users/admin-users.stories.tsx +0 -83
- package/src/blocks/admin-users/admin-users.test.tsx +0 -159
- package/src/blocks/ai-chat/ai-chat.stories.tsx +0 -149
- package/src/blocks/ai-chat/ai-chat.test.tsx +0 -166
- package/src/blocks/dashboard/dashboard.stories.tsx +0 -106
- package/src/blocks/dashboard/dashboard.test.tsx +0 -94
- package/src/blocks/forgot-password/forgot-password.stories.tsx +0 -41
- package/src/blocks/forgot-password/forgot-password.test.tsx +0 -62
- package/src/blocks/login/login.stories.tsx +0 -46
- package/src/blocks/login/login.test.tsx +0 -100
- package/src/blocks/pricing/pricing.stories.tsx +0 -62
- package/src/blocks/pricing/pricing.test.tsx +0 -101
- package/src/blocks/settings/settings.stories.tsx +0 -94
- package/src/blocks/settings/settings.test.tsx +0 -167
- package/src/blocks/signup/signup.stories.tsx +0 -27
- package/src/blocks/signup/signup.test.tsx +0 -97
- package/src/components/accordion/accordion.stories.tsx +0 -90
- package/src/components/accordion/accordion.test.tsx +0 -156
- package/src/components/activity-feed/activity-feed.stories.tsx +0 -163
- package/src/components/activity-feed/activity-feed.test.tsx +0 -125
- package/src/components/ai-agent-status/ai-agent-status.stories.tsx +0 -86
- package/src/components/ai-agent-status/ai-agent-status.test.tsx +0 -66
- package/src/components/ai-conversation/ai-conversation.stories.tsx +0 -126
- package/src/components/ai-conversation/ai-conversation.test.tsx +0 -144
- package/src/components/ai-message/ai-message.stories.tsx +0 -106
- package/src/components/ai-message/ai-message.test.tsx +0 -160
- package/src/components/ai-model-selector/ai-model-selector.stories.tsx +0 -66
- package/src/components/ai-model-selector/ai-model-selector.test.tsx +0 -120
- package/src/components/ai-prompt-input/ai-prompt-input.stories.tsx +0 -145
- package/src/components/ai-prompt-input/ai-prompt-input.test.tsx +0 -273
- package/src/components/ai-reasoning/ai-reasoning.stories.tsx +0 -60
- package/src/components/ai-reasoning/ai-reasoning.test.tsx +0 -95
- package/src/components/ai-response/ai-response.stories.tsx +0 -75
- package/src/components/ai-response/ai-response.test.tsx +0 -96
- package/src/components/ai-sources/ai-sources.stories.tsx +0 -93
- package/src/components/ai-sources/ai-sources.test.tsx +0 -102
- package/src/components/ai-token-usage/ai-token-usage.stories.tsx +0 -59
- package/src/components/ai-token-usage/ai-token-usage.test.tsx +0 -83
- package/src/components/ai-tool/ai-tool.stories.tsx +0 -96
- package/src/components/ai-tool/ai-tool.test.tsx +0 -137
- package/src/components/alert/alert.stories.tsx +0 -101
- package/src/components/alert/alert.test.tsx +0 -94
- package/src/components/avatar/avatar.stories.tsx +0 -58
- package/src/components/avatar/avatar.test.tsx +0 -64
- package/src/components/badge/badge.stories.tsx +0 -68
- package/src/components/badge/badge.test.tsx +0 -61
- package/src/components/button/button.stories.tsx +0 -128
- package/src/components/button/button.test.tsx +0 -224
- package/src/components/calendar/calendar.stories.tsx +0 -105
- package/src/components/calendar/calendar.test.tsx +0 -255
- package/src/components/card/card.stories.tsx +0 -61
- package/src/components/card/card.test.tsx +0 -66
- package/src/components/checkbox/checkbox.stories.tsx +0 -124
- package/src/components/checkbox/checkbox.test.tsx +0 -121
- package/src/components/code-block/code-block.stories.tsx +0 -62
- package/src/components/code-block/code-block.test.tsx +0 -139
- package/src/components/combobox/combobox.stories.tsx +0 -171
- package/src/components/combobox/combobox.test.tsx +0 -429
- package/src/components/command/command.stories.tsx +0 -152
- package/src/components/command/command.test.tsx +0 -380
- package/src/components/data-table/data-table.stories.tsx +0 -151
- package/src/components/data-table/data-table.test.tsx +0 -288
- package/src/components/date-picker/date-picker.stories.tsx +0 -103
- package/src/components/date-picker/date-picker.test.tsx +0 -205
- package/src/components/dialog/dialog.stories.tsx +0 -140
- package/src/components/dialog/dialog.test.tsx +0 -186
- package/src/components/drawer/drawer.stories.tsx +0 -111
- package/src/components/drawer/drawer.test.tsx +0 -187
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +0 -128
- package/src/components/dropdown-menu/dropdown-menu.test.tsx +0 -321
- package/src/components/empty-state/empty-state.stories.tsx +0 -70
- package/src/components/empty-state/empty-state.test.tsx +0 -97
- package/src/components/form/form.stories.tsx +0 -144
- package/src/components/form/form.test.tsx +0 -203
- package/src/components/input/input.stories.tsx +0 -84
- package/src/components/input/input.test.tsx +0 -108
- package/src/components/label/label.stories.tsx +0 -51
- package/src/components/label/label.test.tsx +0 -55
- package/src/components/pagination/pagination.stories.tsx +0 -129
- package/src/components/pagination/pagination.test.tsx +0 -119
- package/src/components/popover/popover.stories.tsx +0 -84
- package/src/components/popover/popover.test.tsx +0 -163
- package/src/components/progress/progress.stories.tsx +0 -90
- package/src/components/progress/progress.test.tsx +0 -78
- package/src/components/radio-group/radio-group.stories.tsx +0 -102
- package/src/components/radio-group/radio-group.test.tsx +0 -152
- package/src/components/select/select.stories.tsx +0 -137
- package/src/components/select/select.test.tsx +0 -209
- package/src/components/separator/separator.stories.tsx +0 -42
- package/src/components/separator/separator.test.tsx +0 -46
- package/src/components/sheet/sheet.stories.tsx +0 -107
- package/src/components/sheet/sheet.test.tsx +0 -152
- package/src/components/skeleton/skeleton.stories.tsx +0 -46
- package/src/components/skeleton/skeleton.test.tsx +0 -57
- package/src/components/slider/slider.stories.tsx +0 -87
- package/src/components/slider/slider.test.tsx +0 -148
- package/src/components/spinner/spinner.stories.tsx +0 -47
- package/src/components/spinner/spinner.test.tsx +0 -41
- package/src/components/switch/switch.stories.tsx +0 -70
- package/src/components/switch/switch.test.tsx +0 -101
- package/src/components/table/table.stories.tsx +0 -131
- package/src/components/table/table.test.tsx +0 -153
- package/src/components/tabs/tabs.stories.tsx +0 -139
- package/src/components/tabs/tabs.test.tsx +0 -148
- package/src/components/toast/toast.stories.tsx +0 -167
- package/src/components/toast/toast.test.tsx +0 -306
- package/src/components/tooltip/tooltip.stories.tsx +0 -89
- package/src/components/tooltip/tooltip.test.tsx +0 -120
- package/src/lib/utils.test.ts +0 -32
- 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,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,
|