@dowel-ui/react 0.2.0 → 0.4.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/README.md +14 -7
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
- package/dist/components/ai-agent-plan/index.d.ts +2 -0
- package/dist/components/ai-agent-plan/index.js +2 -0
- package/dist/components/ai-agent-plan/meta.js +17 -0
- package/dist/components/ai-agent-plan/meta.js.map +1 -0
- package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
- package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
- package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
- package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
- package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
- package/dist/components/ai-approval-request/index.d.ts +2 -0
- package/dist/components/ai-approval-request/index.js +2 -0
- package/dist/components/ai-approval-request/meta.js +17 -0
- package/dist/components/ai-approval-request/meta.js.map +1 -0
- package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
- package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
- package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
- package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
- package/dist/components/ai-disclosure/index.d.ts +2 -0
- package/dist/components/ai-disclosure/index.js +2 -0
- package/dist/components/ai-disclosure/meta.js +17 -0
- package/dist/components/ai-disclosure/meta.js.map +1 -0
- package/dist/components/ai-response/ai-response.d.ts.map +1 -1
- package/dist/components/ai-response/ai-response.js +1 -0
- package/dist/components/ai-response/ai-response.js.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.js +13 -2
- package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -1
- 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/date-picker/date-picker.js +1 -1
- package/dist/components/diff-viewer/diff-model.d.ts +69 -0
- package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-model.js +180 -0
- package/dist/components/diff-viewer/diff-model.js.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.js +212 -0
- package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
- package/dist/components/diff-viewer/index.d.ts +3 -0
- package/dist/components/diff-viewer/index.js +3 -0
- package/dist/components/diff-viewer/meta.js +17 -0
- package/dist/components/diff-viewer/meta.js.map +1 -0
- package/dist/components/file-upload/file-upload.d.ts +46 -0
- package/dist/components/file-upload/file-upload.d.ts.map +1 -0
- package/dist/components/file-upload/file-upload.js +204 -0
- package/dist/components/file-upload/file-upload.js.map +1 -0
- package/dist/components/file-upload/index.d.ts +3 -0
- package/dist/components/file-upload/index.js +3 -0
- package/dist/components/file-upload/meta.js +17 -0
- package/dist/components/file-upload/meta.js.map +1 -0
- package/dist/components/file-upload/upload-queue.d.ts +87 -0
- package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
- package/dist/components/file-upload/upload-queue.js +249 -0
- package/dist/components/file-upload/upload-queue.js.map +1 -0
- package/dist/components/log-viewer/index.d.ts +3 -0
- package/dist/components/log-viewer/index.js +3 -0
- package/dist/components/log-viewer/log-stream.d.ts +68 -0
- package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
- package/dist/components/log-viewer/log-stream.js +120 -0
- package/dist/components/log-viewer/log-stream.js.map +1 -0
- package/dist/components/log-viewer/log-viewer.d.ts +51 -0
- package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
- package/dist/components/log-viewer/log-viewer.js +284 -0
- package/dist/components/log-viewer/log-viewer.js.map +1 -0
- package/dist/components/log-viewer/meta.js +17 -0
- package/dist/components/log-viewer/meta.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/progress/progress.js +1 -0
- package/dist/components/progress/progress.js.map +1 -1
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/spinner/spinner.js +1 -0
- package/dist/components/spinner/spinner.js.map +1 -1
- package/dist/components/tags-input/index.d.ts +2 -0
- package/dist/components/tags-input/index.js +2 -0
- package/dist/components/tags-input/meta.js +17 -0
- package/dist/components/tags-input/meta.js.map +1 -0
- package/dist/components/tags-input/tags-input.d.ts +47 -0
- package/dist/components/tags-input/tags-input.d.ts.map +1 -0
- package/dist/components/tags-input/tags-input.js +182 -0
- package/dist/components/tags-input/tags-input.js.map +1 -0
- package/dist/components/time-range-picker/index.d.ts +3 -0
- package/dist/components/time-range-picker/index.js +3 -0
- package/dist/components/time-range-picker/meta.js +22 -0
- package/dist/components/time-range-picker/meta.js.map +1 -0
- package/dist/components/time-range-picker/time-expression.d.ts +88 -0
- package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-expression.js +335 -0
- package/dist/components/time-range-picker/time-expression.js.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.js +377 -0
- package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +14 -2
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +66 -50
- package/dist/registry/components.js.map +1 -1
- package/package.json +6 -4
- package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
- package/src/components/ai-agent-plan/index.ts +10 -0
- package/src/components/ai-agent-plan/meta.ts +19 -0
- package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
- package/src/components/ai-approval-request/index.ts +7 -0
- package/src/components/ai-approval-request/meta.ts +21 -0
- package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
- package/src/components/ai-disclosure/index.ts +9 -0
- package/src/components/ai-disclosure/meta.ts +23 -0
- package/src/components/ai-response/ai-response.tsx +1 -0
- package/src/components/ai-structured-output/ai-structured-output.tsx +25 -1
- package/src/components/diff-viewer/diff-model.ts +256 -0
- package/src/components/diff-viewer/diff-viewer.tsx +339 -0
- package/src/components/diff-viewer/index.ts +17 -0
- package/src/components/diff-viewer/meta.ts +20 -0
- package/src/components/file-upload/file-upload.tsx +322 -0
- package/src/components/file-upload/index.ts +20 -0
- package/src/components/file-upload/meta.ts +21 -0
- package/src/components/file-upload/upload-queue.ts +337 -0
- package/src/components/log-viewer/index.ts +21 -0
- package/src/components/log-viewer/log-stream.ts +176 -0
- package/src/components/log-viewer/log-viewer.tsx +424 -0
- package/src/components/log-viewer/meta.ts +21 -0
- package/src/components/progress/progress.tsx +3 -0
- package/src/components/spinner/spinner.tsx +5 -0
- package/src/components/tags-input/index.ts +1 -0
- package/src/components/tags-input/meta.ts +23 -0
- package/src/components/tags-input/tags-input.tsx +300 -0
- package/src/components/time-range-picker/index.ts +25 -0
- package/src/components/time-range-picker/meta.ts +22 -0
- package/src/components/time-range-picker/time-expression.ts +491 -0
- package/src/components/time-range-picker/time-range-picker.tsx +522 -0
- package/src/index.ts +8 -0
- package/src/registry/components.ts +16 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
|
|
7
|
+
import { focusRing, iconSlot } from "@/lib/styles";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Telling someone they are looking at AI.
|
|
12
|
+
*
|
|
13
|
+
* Not one registry ships this — not AI Elements, assistant-ui, prompt-kit,
|
|
14
|
+
* CopilotKit or shadcn — while every one of them ships the chat surface that
|
|
15
|
+
* needs it. The gap is the reason this exists.
|
|
16
|
+
*
|
|
17
|
+
* The four `kind`s are not invented. They are the four human-visible situations
|
|
18
|
+
* the EU AI Act's Article 50 actually creates, which has applied since 2 August
|
|
19
|
+
* 2026: you are talking to a machine (50(1)); this media was generated or
|
|
20
|
+
* manipulated and resembles something real (50(4), "deep fakes"); this text was
|
|
21
|
+
* published to inform the public (50(4)); and the assisted case, which the same
|
|
22
|
+
* paragraph exempts where there is human review or editorial control — worth
|
|
23
|
+
* saying out loud rather than leaving to inference.
|
|
24
|
+
*
|
|
25
|
+
* What this cannot do, and what no React component can: Article 50(2) requires
|
|
26
|
+
* synthetic output to be marked *in a machine-readable format*, in the artifact
|
|
27
|
+
* itself, by whoever generated it. Rendering a badge does not do that, and a
|
|
28
|
+
* component that let you believe otherwise would be worse than none. Read the
|
|
29
|
+
* accessibility note and the docs page: this is a disclosure control, not a
|
|
30
|
+
* compliance product, and nothing here is legal advice.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export type DisclosureKind = "interaction" | "generated" | "manipulated" | "assisted";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What each kind says by default.
|
|
37
|
+
*
|
|
38
|
+
* Phrased for a reader rather than for a lawyer, and every one of them is
|
|
39
|
+
* overridable — the Act says the disclosure must be clear, not that it must use
|
|
40
|
+
* these words, and the right words depend on the product.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_LABEL: Record<DisclosureKind, string> = {
|
|
43
|
+
interaction: "You are chatting with an AI assistant",
|
|
44
|
+
generated: "Generated by AI",
|
|
45
|
+
manipulated: "This media was altered by AI",
|
|
46
|
+
assisted: "AI-assisted, reviewed by a person",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const disclosureVariants = cva(
|
|
50
|
+
cn(
|
|
51
|
+
"inline-flex w-fit items-center gap-1.5 font-medium",
|
|
52
|
+
"[&_svg:not([class*='size-'])]:size-3.5",
|
|
53
|
+
iconSlot,
|
|
54
|
+
),
|
|
55
|
+
{
|
|
56
|
+
variants: {
|
|
57
|
+
variant: {
|
|
58
|
+
/** Sits beside the thing it describes. */
|
|
59
|
+
inline: "rounded-full border border-border-strong px-2 py-0.5 text-xs",
|
|
60
|
+
/** Above a conversation, where Article 50(1) usually belongs. */
|
|
61
|
+
banner:
|
|
62
|
+
"w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
|
|
63
|
+
/**
|
|
64
|
+
* Over the corner of an image or video. `--overlay` is a dark scrim in
|
|
65
|
+
* every shipped theme, so the text is white on purpose rather than
|
|
66
|
+
* following the theme — a label that inverts over a photograph is
|
|
67
|
+
* unreadable on half of them.
|
|
68
|
+
*/
|
|
69
|
+
overlay: "rounded-md bg-overlay px-2 py-1 text-2xs text-white backdrop-blur-sm",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
defaultVariants: { variant: "inline" },
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function SparkIcon() {
|
|
77
|
+
return (
|
|
78
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
79
|
+
<path
|
|
80
|
+
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
strokeWidth="2"
|
|
83
|
+
strokeLinejoin="round"
|
|
84
|
+
/>
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AIDisclosureProps
|
|
90
|
+
extends
|
|
91
|
+
Omit<ComponentPropsWithRef<"div">, "children">,
|
|
92
|
+
VariantProps<typeof disclosureVariants> {
|
|
93
|
+
kind: DisclosureKind;
|
|
94
|
+
/** Replaces the default wording. The default is a starting point, not a rule. */
|
|
95
|
+
label?: ReactNode;
|
|
96
|
+
/**
|
|
97
|
+
* Replaces the mark. The Commission publishes three icons for this and lets
|
|
98
|
+
* anyone use them without attribution — they are deliberately not bundled
|
|
99
|
+
* here, because an official mark shipped inside a component library ends up
|
|
100
|
+
* on content nobody checked. Bring your own, and the Commission's own words
|
|
101
|
+
* apply: using them "does not establish legal compliance by itself".
|
|
102
|
+
*/
|
|
103
|
+
icon?: ReactNode;
|
|
104
|
+
/** A provenance panel, or anything else that belongs after the label. */
|
|
105
|
+
children?: ReactNode;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A disclosure, in words.
|
|
110
|
+
*
|
|
111
|
+
* The icon is decorative and the text is the disclosure. A sparkle alone is not
|
|
112
|
+
* a notice — it is a decoration that a sighted reader may or may not decode and
|
|
113
|
+
* that a screen reader passes over in silence, which is the failure mode the
|
|
114
|
+
* whole component exists to avoid.
|
|
115
|
+
*/
|
|
116
|
+
export function AIDisclosure({
|
|
117
|
+
className,
|
|
118
|
+
kind,
|
|
119
|
+
variant,
|
|
120
|
+
label,
|
|
121
|
+
icon,
|
|
122
|
+
children,
|
|
123
|
+
...props
|
|
124
|
+
}: AIDisclosureProps) {
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
data-slot="ai-disclosure"
|
|
128
|
+
data-kind={kind}
|
|
129
|
+
className={cn(disclosureVariants({ variant }), className)}
|
|
130
|
+
{...props}
|
|
131
|
+
>
|
|
132
|
+
{icon ?? <SparkIcon />}
|
|
133
|
+
<span>{label ?? DEFAULT_LABEL[kind]}</span>
|
|
134
|
+
{children}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ------------------------------------------------------------------ */
|
|
140
|
+
/* Provenance */
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
|
|
143
|
+
export interface ProvenanceClaim {
|
|
144
|
+
label: string;
|
|
145
|
+
value: ReactNode;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface AIDisclosureProvenanceProps extends Omit<
|
|
149
|
+
ComponentPropsWithRef<"button">,
|
|
150
|
+
"children"
|
|
151
|
+
> {
|
|
152
|
+
/**
|
|
153
|
+
* What is being claimed about this content, and by whom.
|
|
154
|
+
*
|
|
155
|
+
* Claims, not findings. They arrive as data — from a C2PA manifest your
|
|
156
|
+
* server read, from your own pipeline, from a vendor — and this renders them.
|
|
157
|
+
*/
|
|
158
|
+
claims: ProvenanceClaim[];
|
|
159
|
+
/** Who asserts the claims. Named, because an unattributed claim is a rumour. */
|
|
160
|
+
source?: string;
|
|
161
|
+
/**
|
|
162
|
+
* Whether something you trust checked the claims, and said so.
|
|
163
|
+
*
|
|
164
|
+
* Supplied, never computed. Verifying a C2PA manifest means parsing signed
|
|
165
|
+
* COSE and walking a certificate chain; doing that in the browser would mean
|
|
166
|
+
* a wasm blob you cannot read, and "you own the source" would stop being
|
|
167
|
+
* true. It also cannot be trusted client-side even when it succeeds, because
|
|
168
|
+
* the page doing the checking is the page making the claim. Verify on a
|
|
169
|
+
* server and pass the answer down. Left undefined, this says so.
|
|
170
|
+
*/
|
|
171
|
+
verified?: boolean;
|
|
172
|
+
triggerLabel?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type CheckStatus = "verified" | "failed" | "unchecked";
|
|
176
|
+
|
|
177
|
+
function statusText(status: CheckStatus, source?: string): string {
|
|
178
|
+
const by = source ? ` by ${source}` : "";
|
|
179
|
+
if (status === "verified") return `Checked${by} and the claims matched.`;
|
|
180
|
+
if (status === "failed") return `Checked${by} and the claims did not match.`;
|
|
181
|
+
return "Not checked. These are claims made about the content, not findings.";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The metadata behind a disclosure, presented as claims rather than proof.
|
|
186
|
+
*
|
|
187
|
+
* The distinction is the entire point. "Made with Model X on 3 September" looks
|
|
188
|
+
* like a fact and is a string somebody put in a file. Rendering it beside a
|
|
189
|
+
* checkmark would launder an assertion into evidence, so the panel names who is
|
|
190
|
+
* asserting and states plainly whether anyone checked.
|
|
191
|
+
*/
|
|
192
|
+
export function AIDisclosureProvenance({
|
|
193
|
+
className,
|
|
194
|
+
claims,
|
|
195
|
+
source,
|
|
196
|
+
verified,
|
|
197
|
+
triggerLabel = "Content details",
|
|
198
|
+
...props
|
|
199
|
+
}: AIDisclosureProvenanceProps) {
|
|
200
|
+
const status = verified === undefined ? "unchecked" : verified ? "verified" : "failed";
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<Popover>
|
|
204
|
+
<PopoverTrigger asChild>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
data-slot="ai-disclosure-provenance-trigger"
|
|
208
|
+
className={cn(
|
|
209
|
+
"rounded-sm underline decoration-dotted underline-offset-2 hover:decoration-solid",
|
|
210
|
+
focusRing,
|
|
211
|
+
className,
|
|
212
|
+
)}
|
|
213
|
+
{...props}
|
|
214
|
+
>
|
|
215
|
+
{triggerLabel}
|
|
216
|
+
</button>
|
|
217
|
+
</PopoverTrigger>
|
|
218
|
+
<PopoverContent aria-label={triggerLabel} className="w-80 text-xs">
|
|
219
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
|
220
|
+
{claims.map((claim) => (
|
|
221
|
+
<div key={claim.label} className="contents">
|
|
222
|
+
<dt className="text-muted-foreground">{claim.label}</dt>
|
|
223
|
+
<dd className="break-words">{claim.value}</dd>
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</dl>
|
|
227
|
+
{/* Said in text, and last, so it qualifies everything above it rather
|
|
228
|
+
than being a tick a reader has to interpret. */}
|
|
229
|
+
<p
|
|
230
|
+
data-slot="ai-disclosure-provenance-status"
|
|
231
|
+
data-status={status}
|
|
232
|
+
className={cn(
|
|
233
|
+
"mt-3 border-t border-border pt-2",
|
|
234
|
+
status === "failed" ? "text-destructive" : "text-muted-foreground",
|
|
235
|
+
)}
|
|
236
|
+
>
|
|
237
|
+
{statusText(status, source)}
|
|
238
|
+
</p>
|
|
239
|
+
</PopoverContent>
|
|
240
|
+
</Popover>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export { disclosureVariants };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "ai-disclosure",
|
|
5
|
+
title: "AI Disclosure",
|
|
6
|
+
description: "Tells a reader they are looking at AI, and what is actually known about it.",
|
|
7
|
+
category: "ai",
|
|
8
|
+
status: "stable",
|
|
9
|
+
dependencies: ["class-variance-authority"],
|
|
10
|
+
registryDependencies: ["popover"],
|
|
11
|
+
files: ["ai-disclosure.tsx"],
|
|
12
|
+
a11y:
|
|
13
|
+
"The text is the disclosure and the icon is decoration, marked aria-hidden — a sparkle alone " +
|
|
14
|
+
"is a mark a sighted reader may not decode and a screen reader passes over in silence, which " +
|
|
15
|
+
"is the exact failure this component exists to prevent. Each of the four kinds carries " +
|
|
16
|
+
"wording of its own, so the distinction between generated, manipulated and human-reviewed " +
|
|
17
|
+
"survives for anyone who cannot see the styling. The provenance panel states in words " +
|
|
18
|
+
"whether anyone checked the claims and who, rather than implying it with a tick, and its " +
|
|
19
|
+
"three states are told apart by their sentences rather than by colour. " +
|
|
20
|
+
"Scope: this renders the human-visible disclosures of EU AI Act Article 50(1) and 50(4). " +
|
|
21
|
+
"It cannot satisfy 50(2), which requires machine-readable marking inside the artifact by " +
|
|
22
|
+
"whoever generated it, and no React component can. Not legal advice.",
|
|
23
|
+
});
|
|
@@ -50,6 +50,7 @@ export function ResponseCaret({ className, ...props }: ComponentPropsWithRef<"sp
|
|
|
50
50
|
<span
|
|
51
51
|
data-slot="response-caret"
|
|
52
52
|
aria-hidden="true"
|
|
53
|
+
data-motion="indicator"
|
|
53
54
|
className={cn(
|
|
54
55
|
"ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] animate-caret bg-current align-baseline",
|
|
55
56
|
className,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createContext,
|
|
5
5
|
useContext,
|
|
6
6
|
useMemo,
|
|
7
|
+
useState,
|
|
7
8
|
type ComponentPropsWithRef,
|
|
8
9
|
type ReactNode,
|
|
9
10
|
} from "react";
|
|
@@ -167,10 +168,28 @@ export function StructuredField({ className, name, children, ...props }: Structu
|
|
|
167
168
|
const state = stateOf(name);
|
|
168
169
|
const raw = value[name];
|
|
169
170
|
|
|
171
|
+
// "A token arrived" and "this field is final" are different facts, and in a
|
|
172
|
+
// streamed object they look identical. The settle makes that distinction
|
|
173
|
+
// visible — so it plays on the transition into settled, and never on a field
|
|
174
|
+
// that was already final when the component mounted.
|
|
175
|
+
//
|
|
176
|
+
// State adjusted during render rather than tracked in a ref: reading a ref
|
|
177
|
+
// while rendering is unsafe under concurrent rendering, and an effect would
|
|
178
|
+
// paint the plain value first and animate a frame later. React re-renders
|
|
179
|
+
// immediately here and discards the intermediate pass.
|
|
180
|
+
const [seenState, setSeenState] = useState<FieldState>(state);
|
|
181
|
+
const [hasSettled, setHasSettled] = useState(false);
|
|
182
|
+
|
|
183
|
+
if (seenState !== state) {
|
|
184
|
+
setSeenState(state);
|
|
185
|
+
if (state === "settled" && seenState !== "settled") setHasSettled(true);
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
return (
|
|
171
189
|
<div
|
|
172
190
|
data-slot="structured-field"
|
|
173
191
|
data-state={state}
|
|
192
|
+
data-settled={hasSettled || undefined}
|
|
174
193
|
data-field={name}
|
|
175
194
|
className={cn("flex flex-col gap-1", className)}
|
|
176
195
|
{...props}
|
|
@@ -195,7 +214,12 @@ export function StructuredField({ className, name, children, ...props }: Structu
|
|
|
195
214
|
className="block h-4 w-24 animate-pulse-soft rounded bg-muted"
|
|
196
215
|
/>
|
|
197
216
|
) : (
|
|
198
|
-
|
|
217
|
+
<span
|
|
218
|
+
data-slot="structured-field-value"
|
|
219
|
+
className={cn(hasSettled && "inline-block animate-settle")}
|
|
220
|
+
>
|
|
221
|
+
{children ?? formatValue(raw, name)}
|
|
222
|
+
</span>
|
|
199
223
|
)}
|
|
200
224
|
</dd>
|
|
201
225
|
</div>
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { diffLines, diffWordsWithSpace } from "diff";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turning two versions of a file into rows a diff can render.
|
|
5
|
+
*
|
|
6
|
+
* The algorithm is not the interesting part and is not reimplemented here —
|
|
7
|
+
* jsdiff does Myers properly, is BSD-licensed, has no dependencies of its own,
|
|
8
|
+
* and getting an O(ND) diff subtly wrong is a poor use of anybody's afternoon.
|
|
9
|
+
* What every packaged *viewer* welds on is a styling strategy: emotion in
|
|
10
|
+
* react-diff-viewer-continued, HTML strings and a stylesheet in diff2html. That
|
|
11
|
+
* is what cannot be reached by design tokens, and it is why this exists.
|
|
12
|
+
*
|
|
13
|
+
* Everything below is pure, so the grouping, the context collapsing and the
|
|
14
|
+
* word-level pairing can be tested without rendering anything.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type RowKind = "context" | "added" | "removed";
|
|
18
|
+
|
|
19
|
+
export interface WordSegment {
|
|
20
|
+
text: string;
|
|
21
|
+
changed: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DiffRow {
|
|
25
|
+
kind: RowKind;
|
|
26
|
+
/** 1-based line number in the original. Absent on an added line. */
|
|
27
|
+
before?: number;
|
|
28
|
+
/** 1-based line number in the result. Absent on a removed line. */
|
|
29
|
+
after?: number;
|
|
30
|
+
content: string;
|
|
31
|
+
/**
|
|
32
|
+
* Word-level split, present only where a removed line pairs with an added
|
|
33
|
+
* one. Without a pair there is nothing to compare against, and highlighting
|
|
34
|
+
* the whole line as "changed" would be noise.
|
|
35
|
+
*/
|
|
36
|
+
segments?: WordSegment[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DiffHunk {
|
|
40
|
+
id: string;
|
|
41
|
+
rows: DiffRow[];
|
|
42
|
+
/** Context lines hidden before this hunk, if any. */
|
|
43
|
+
skippedBefore: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BuildDiffOptions {
|
|
47
|
+
/** Unchanged lines kept either side of a change. */
|
|
48
|
+
context?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Compare word by word inside a changed line. Off for very large diffs,
|
|
51
|
+
* where the extra pass costs more than it explains.
|
|
52
|
+
*/
|
|
53
|
+
words?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function splitLines(text: string): string[] {
|
|
57
|
+
const lines = text.split("\n");
|
|
58
|
+
// A trailing newline produces a final empty element that is not a line.
|
|
59
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Gives both sides a trailing newline before they are compared.
|
|
65
|
+
*
|
|
66
|
+
* jsdiff's line tokens carry their own newline, so a final line written as `a`
|
|
67
|
+
* and one written as `a\n` are different tokens. Appending a line to a file
|
|
68
|
+
* that did not end in a newline would otherwise be reported as the last line
|
|
69
|
+
* being removed and re-added — a change the author did not make, sitting on
|
|
70
|
+
* top of the one they did.
|
|
71
|
+
*/
|
|
72
|
+
function withTrailingNewline(text: string): string {
|
|
73
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pairs removed lines with added ones inside a single change block.
|
|
78
|
+
*
|
|
79
|
+
* jsdiff reports a change as a run of removals followed by a run of additions.
|
|
80
|
+
* Comparing the first removal against the first addition is what turns "this
|
|
81
|
+
* line went away and another arrived" into "this word changed" — but only while
|
|
82
|
+
* the runs line up. Beyond the shorter run there is no counterpart, and
|
|
83
|
+
* inventing one produces confident nonsense.
|
|
84
|
+
*/
|
|
85
|
+
function pairWords(removed: DiffRow[], added: DiffRow[]): void {
|
|
86
|
+
const pairs = Math.min(removed.length, added.length);
|
|
87
|
+
|
|
88
|
+
for (let index = 0; index < pairs; index += 1) {
|
|
89
|
+
const from = removed[index];
|
|
90
|
+
const to = added[index];
|
|
91
|
+
if (!from || !to) continue;
|
|
92
|
+
|
|
93
|
+
const parts = diffWordsWithSpace(from.content, to.content);
|
|
94
|
+
|
|
95
|
+
from.segments = parts
|
|
96
|
+
.filter((part) => !part.added)
|
|
97
|
+
.map((part) => ({ text: part.value, changed: Boolean(part.removed) }));
|
|
98
|
+
|
|
99
|
+
to.segments = parts
|
|
100
|
+
.filter((part) => !part.removed)
|
|
101
|
+
.map((part) => ({ text: part.value, changed: Boolean(part.added) }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildDiff(
|
|
106
|
+
before: string,
|
|
107
|
+
after: string,
|
|
108
|
+
options: BuildDiffOptions = {},
|
|
109
|
+
): DiffHunk[] {
|
|
110
|
+
const { context = 3, words = true } = options;
|
|
111
|
+
|
|
112
|
+
const changes = diffLines(withTrailingNewline(before), withTrailingNewline(after));
|
|
113
|
+
const rows: DiffRow[] = [];
|
|
114
|
+
let beforeLine = 1;
|
|
115
|
+
let afterLine = 1;
|
|
116
|
+
|
|
117
|
+
// Flat rows first. Grouping into hunks is a separate concern and mixing the
|
|
118
|
+
// two is how these implementations become unreadable.
|
|
119
|
+
let pendingRemoved: DiffRow[] = [];
|
|
120
|
+
let pendingAdded: DiffRow[] = [];
|
|
121
|
+
|
|
122
|
+
const flushPair = () => {
|
|
123
|
+
if (words && pendingRemoved.length > 0 && pendingAdded.length > 0) {
|
|
124
|
+
pairWords(pendingRemoved, pendingAdded);
|
|
125
|
+
}
|
|
126
|
+
pendingRemoved = [];
|
|
127
|
+
pendingAdded = [];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (const change of changes) {
|
|
131
|
+
const lines = splitLines(change.value);
|
|
132
|
+
|
|
133
|
+
if (change.added) {
|
|
134
|
+
for (const content of lines) {
|
|
135
|
+
const row: DiffRow = { kind: "added", after: afterLine, content };
|
|
136
|
+
afterLine += 1;
|
|
137
|
+
rows.push(row);
|
|
138
|
+
pendingAdded.push(row);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (change.removed) {
|
|
144
|
+
// A removal run that follows an addition run starts a new pairing.
|
|
145
|
+
if (pendingAdded.length > 0) flushPair();
|
|
146
|
+
for (const content of lines) {
|
|
147
|
+
const row: DiffRow = { kind: "removed", before: beforeLine, content };
|
|
148
|
+
beforeLine += 1;
|
|
149
|
+
rows.push(row);
|
|
150
|
+
pendingRemoved.push(row);
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
flushPair();
|
|
156
|
+
for (const content of lines) {
|
|
157
|
+
rows.push({ kind: "context", before: beforeLine, after: afterLine, content });
|
|
158
|
+
beforeLine += 1;
|
|
159
|
+
afterLine += 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
flushPair();
|
|
164
|
+
|
|
165
|
+
return groupIntoHunks(rows, context);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Groups rows into hunks, dropping context beyond `context` lines.
|
|
170
|
+
*
|
|
171
|
+
* A file with one changed line is otherwise thousands of rows of identical
|
|
172
|
+
* text, and the reader has to find the change in it.
|
|
173
|
+
*/
|
|
174
|
+
export function groupIntoHunks(rows: DiffRow[], context: number): DiffHunk[] {
|
|
175
|
+
const changedIndexes = rows
|
|
176
|
+
.map((row, index) => (row.kind === "context" ? -1 : index))
|
|
177
|
+
.filter((index) => index >= 0);
|
|
178
|
+
|
|
179
|
+
if (changedIndexes.length === 0) return [];
|
|
180
|
+
|
|
181
|
+
// Ranges of rows to keep, then merged where their context overlaps —
|
|
182
|
+
// otherwise two nearby changes produce two hunks separated by nothing.
|
|
183
|
+
const ranges: [number, number][] = [];
|
|
184
|
+
for (const index of changedIndexes) {
|
|
185
|
+
const start = Math.max(0, index - context);
|
|
186
|
+
const end = Math.min(rows.length - 1, index + context);
|
|
187
|
+
const last = ranges[ranges.length - 1];
|
|
188
|
+
if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
|
|
189
|
+
else ranges.push([start, end]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let previousEnd = -1;
|
|
193
|
+
return ranges.map(([start, end], index) => {
|
|
194
|
+
const skippedBefore = start - previousEnd - 1;
|
|
195
|
+
previousEnd = end;
|
|
196
|
+
return {
|
|
197
|
+
id: `hunk-${String(index)}`,
|
|
198
|
+
rows: rows.slice(start, end + 1),
|
|
199
|
+
skippedBefore: Math.max(0, skippedBefore),
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Row pairs for a side-by-side view, aligning removals against additions. */
|
|
205
|
+
export function toSplitRows(
|
|
206
|
+
rows: DiffRow[],
|
|
207
|
+
): { left: DiffRow | null; right: DiffRow | null }[] {
|
|
208
|
+
const pairs: { left: DiffRow | null; right: DiffRow | null }[] = [];
|
|
209
|
+
let index = 0;
|
|
210
|
+
|
|
211
|
+
while (index < rows.length) {
|
|
212
|
+
const row = rows[index];
|
|
213
|
+
if (!row) break;
|
|
214
|
+
|
|
215
|
+
if (row.kind === "context") {
|
|
216
|
+
pairs.push({ left: row, right: row });
|
|
217
|
+
index += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Take the whole removal run and the whole addition run that follows, then
|
|
222
|
+
// lay them alongside each other. Emitting them in document order instead
|
|
223
|
+
// would put every removal above every addition, which is the unified view
|
|
224
|
+
// wearing a two-column costume.
|
|
225
|
+
const removed: DiffRow[] = [];
|
|
226
|
+
while (rows[index]?.kind === "removed") {
|
|
227
|
+
removed.push(rows[index] as DiffRow);
|
|
228
|
+
index += 1;
|
|
229
|
+
}
|
|
230
|
+
const added: DiffRow[] = [];
|
|
231
|
+
while (rows[index]?.kind === "added") {
|
|
232
|
+
added.push(rows[index] as DiffRow);
|
|
233
|
+
index += 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const height = Math.max(removed.length, added.length);
|
|
237
|
+
for (let offset = 0; offset < height; offset += 1) {
|
|
238
|
+
pairs.push({ left: removed[offset] ?? null, right: added[offset] ?? null });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return pairs;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Counts, for a summary that says what the diff does before it is read. */
|
|
246
|
+
export function countChanges(hunks: DiffHunk[]): { added: number; removed: number } {
|
|
247
|
+
let added = 0;
|
|
248
|
+
let removed = 0;
|
|
249
|
+
for (const hunk of hunks) {
|
|
250
|
+
for (const row of hunk.rows) {
|
|
251
|
+
if (row.kind === "added") added += 1;
|
|
252
|
+
if (row.kind === "removed") removed += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { added, removed };
|
|
256
|
+
}
|