@candor-ui/react 1.7.0 → 1.9.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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * MCP's "input required" results (spec 2026-07-28, multi round-trip requests) as Candor data: a server's form becomes
3
+ * questions for ClarifyingQuestions, a plain confirmation or a link to open becomes a two-answer Decision for
4
+ * ApprovalDecision, and the person's answers become the `inputResponses` the client sends when it retries the call.
5
+ * Requests it can't show (sampling, roots, anything newer) stay listed as not supported, never dropped.
6
+ *
7
+ * Pure functions on plain data, so they run in the browser or on the server, with any MCP client.
8
+ *
9
+ * Checked against https://modelcontextprotocol.io/specification/2026-07-28 (basic/patterns/mrtr, client/elicitation
10
+ * and the schema): `resultType: "input_required"`, `inputRequests` as a map keyed by the server's ids, `requestState`
11
+ * echoed back unchanged, and the retry's `inputResponses` keyed the same way. Elicitation results are
12
+ * `{ action: "accept" | "decline" | "cancel", content? }`.
13
+ */
14
+ /** The words this file writes into questions, decisions and problems. Pass your own to `mcpInputView` or `mcpInputResponses`. */
15
+ export const mcpInputWords = {
16
+ yes: "Yes",
17
+ no: "No",
18
+ /** The extra option on a choice the server doesn't require. */
19
+ skip: "Leave unanswered",
20
+ defaultHint: "Server’s default: {value}",
21
+ formatEmail: "name@example.com",
22
+ formatUri: "https://…",
23
+ formatDate: "YYYY-MM-DD",
24
+ formatDateTime: "YYYY-MM-DDTHH:MM",
25
+ confirm: "Go ahead",
26
+ openPage: "Open the page",
27
+ decline: "Decline",
28
+ host: "Opens",
29
+ address: "Full address",
30
+ punycode: "The address uses an encoded name (xn--), which can imitate another site.",
31
+ notHttps: "The page isn’t on https.",
32
+ badUrl: "The server sent an address that isn’t a valid URL, so it can’t be opened.",
33
+ unsupportedSampling: "The server asks the client to run a model prompt (sampling), which this app doesn’t do here.",
34
+ unsupportedRoots: "The server asks for the client’s folders (roots), which this app doesn’t answer here.",
35
+ unsupportedMethod: "The server asks for something this app can’t show yet: {method}.",
36
+ unsupportedShape: "The server’s request isn’t in a shape this app recognises.",
37
+ unsupportedField: "The form has a field this app can’t show: {field}.",
38
+ unanswered: "Not answered yet.",
39
+ required: "An answer is needed.",
40
+ notAnOption: "That isn’t one of the choices.",
41
+ notANumber: "That isn’t a number.",
42
+ notAnInteger: "That needs to be a whole number.",
43
+ tooSmall: "That needs to be at least {min}.",
44
+ tooLarge: "That needs to be at most {max}.",
45
+ tooShort: "That needs at least {min} characters.",
46
+ tooLong: "That allows at most {max} characters.",
47
+ format: "That doesn’t look like {format}.",
48
+ tooFew: "Choose at least {min}.",
49
+ tooMany: "Choose at most {max}.",
50
+ };
51
+ const fill = (s, vars) => s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
52
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
53
+ /** The id of "Leave unanswered" on a choice the server doesn't require; no enum value or "true"/"false" can clash with it. */
54
+ const SKIP = "\u0000skip";
55
+ /* ---------------------------------------------------------------- result → view */
56
+ /**
57
+ * Whether a tools/call (or prompts/get, resources/read) result asks for more input. A result without `resultType`
58
+ * comes from an older server and counts as complete, as the spec says.
59
+ */
60
+ export function isMcpInputRequired(result) {
61
+ return isObject(result) && result.resultType === "input_required";
62
+ }
63
+ /**
64
+ * An input-required result as items to show: forms as questions, confirmations and links as decisions, and anything
65
+ * else as a visible "not supported". Undefined when the result doesn't ask for input. `server` is your app's own name
66
+ * for the server, which the person should see beside every request.
67
+ */
68
+ export function mcpInputView(result, options = {}) {
69
+ if (!isMcpInputRequired(result))
70
+ return undefined;
71
+ const w = { ...mcpInputWords, ...options.words };
72
+ const requests = isObject(result.inputRequests) ? result.inputRequests : {};
73
+ const items = Object.entries(requests).map(([key, request]) => itemOf(key, request, w));
74
+ const info = isObject(result._meta) ? result._meta["io.modelcontextprotocol/serverInfo"] : undefined;
75
+ const claims = isObject(info) ? (typeof info.title === "string" && info.title) || (typeof info.name === "string" && info.name) || undefined : undefined;
76
+ return {
77
+ items,
78
+ ...(typeof result.requestState === "string" && { requestState: result.requestState }),
79
+ ...(options.server && { server: options.server }),
80
+ ...(claims && { serverClaims: claims }),
81
+ };
82
+ }
83
+ function itemOf(key, request, w) {
84
+ const unsupported = (reason, method) => ({ key, kind: "unsupported", ...(method && { method }), reason, request });
85
+ if (!isObject(request) || typeof request.method !== "string")
86
+ return unsupported(w.unsupportedShape);
87
+ const method = request.method;
88
+ if (method === "sampling/createMessage")
89
+ return unsupported(w.unsupportedSampling, method);
90
+ if (method === "roots/list")
91
+ return unsupported(w.unsupportedRoots, method);
92
+ if (method !== "elicitation/create")
93
+ return unsupported(fill(w.unsupportedMethod, { method }), method);
94
+ const params = request.params;
95
+ if (!isObject(params) || typeof params.message !== "string")
96
+ return unsupported(w.unsupportedShape, method);
97
+ const message = params.message;
98
+ const mode = params.mode ?? "form";
99
+ if (mode === "url") {
100
+ if (typeof params.url !== "string")
101
+ return unsupported(w.unsupportedShape, method);
102
+ let parsed;
103
+ try {
104
+ parsed = new URL(params.url);
105
+ }
106
+ catch {
107
+ return unsupported(w.badUrl, method);
108
+ }
109
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
110
+ return unsupported(w.badUrl, method);
111
+ const warnings = [];
112
+ if (parsed.hostname.split(".").some((part) => part.startsWith("xn--")))
113
+ warnings.push(w.punycode);
114
+ if (parsed.protocol !== "https:")
115
+ warnings.push(w.notHttps);
116
+ return {
117
+ key,
118
+ kind: "url",
119
+ message,
120
+ url: params.url,
121
+ host: parsed.host,
122
+ warnings,
123
+ decision: {
124
+ id: key,
125
+ question: message,
126
+ facts: [
127
+ { label: w.host, value: parsed.host },
128
+ { label: w.address, value: params.url },
129
+ ],
130
+ choices: [
131
+ { id: "accept", label: w.openPage },
132
+ { id: "decline", label: w.decline },
133
+ ],
134
+ state: "open",
135
+ },
136
+ };
137
+ }
138
+ if (mode !== "form")
139
+ return unsupported(fill(w.unsupportedMethod, { method: `${method} (${String(mode)})` }), method);
140
+ const schema = params.requestedSchema;
141
+ if (!isObject(schema) || (schema.properties !== undefined && !isObject(schema.properties)))
142
+ return unsupported(w.unsupportedShape, method);
143
+ const properties = (schema.properties ?? {});
144
+ const required = new Set(Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []);
145
+ const names = Object.keys(properties);
146
+ if (!names.length) {
147
+ return {
148
+ key,
149
+ kind: "confirm",
150
+ message,
151
+ decision: {
152
+ id: key,
153
+ question: message,
154
+ choices: [
155
+ { id: "accept", label: w.confirm },
156
+ { id: "decline", label: w.decline },
157
+ ],
158
+ state: "open",
159
+ },
160
+ };
161
+ }
162
+ const questions = [];
163
+ for (const name of names) {
164
+ const q = questionOf(name, properties[name], required.has(name), w);
165
+ // One field it can't show makes the whole form unsupported: answering only part of it would send a half-truth.
166
+ if (!q)
167
+ return unsupported(fill(w.unsupportedField, { field: name }), method);
168
+ questions.push(q);
169
+ }
170
+ return { key, kind: "questions", message, questions, fields: properties, required: [...required] };
171
+ }
172
+ /** A field's options, when it is an enum (single or multiple); undefined otherwise. */
173
+ function optionsOf(s) {
174
+ const titled = (list) => Array.isArray(list) && list.every((o) => isObject(o) && typeof o.const === "string")
175
+ ? list.map((o) => ({ id: o.const, label: typeof o.title === "string" && o.title ? o.title : o.const }))
176
+ : undefined;
177
+ const plain = (list, names) => Array.isArray(list) && list.every((v) => typeof v === "string")
178
+ ? list.map((v, i) => ({ id: v, label: Array.isArray(names) && typeof names[i] === "string" && names[i] ? names[i] : v }))
179
+ : undefined;
180
+ if (s.type === "array") {
181
+ const items = isObject(s.items) ? s.items : undefined;
182
+ const options = items ? (items.anyOf !== undefined ? titled(items.anyOf) : plain(items.enum)) : undefined;
183
+ return options ? { options, multiple: true } : undefined;
184
+ }
185
+ if (s.type === "string" || s.type === undefined) {
186
+ if (s.oneOf !== undefined) {
187
+ const options = titled(s.oneOf);
188
+ return options ? { options, multiple: false } : undefined;
189
+ }
190
+ if (s.enum !== undefined) {
191
+ const options = plain(s.enum, s.enumNames);
192
+ return options ? { options, multiple: false } : undefined;
193
+ }
194
+ }
195
+ return undefined;
196
+ }
197
+ const formatHint = (format, w) => format === "email" ? w.formatEmail : format === "uri" ? w.formatUri : format === "date" ? w.formatDate : format === "date-time" ? w.formatDateTime : undefined;
198
+ function questionOf(name, raw, required, w) {
199
+ if (!isObject(raw))
200
+ return undefined;
201
+ const s = raw;
202
+ const title = typeof s.title === "string" && s.title ? s.title : undefined;
203
+ const description = typeof s.description === "string" && s.description ? s.description : undefined;
204
+ const prompt = description ?? title ?? name;
205
+ const tag = description && title ? title : undefined;
206
+ const head = { id: name, prompt, ...(tag && { tag }) };
207
+ const enumOf = optionsOf(s);
208
+ if (enumOf) {
209
+ const options = !required && !enumOf.multiple ? [...enumOf.options, { id: SKIP, label: w.skip }] : enumOf.options;
210
+ const recommended = typeof s.default === "string" && enumOf.options.some((o) => o.id === s.default) ? s.default : undefined;
211
+ return { ...head, kind: "choice", options, multiple: enumOf.multiple, other: false, ...(recommended && { recommended }) };
212
+ }
213
+ if (s.type === "boolean") {
214
+ const options = [
215
+ { id: "true", label: w.yes },
216
+ { id: "false", label: w.no },
217
+ ...(required ? [] : [{ id: SKIP, label: w.skip }]),
218
+ ];
219
+ const recommended = typeof s.default === "boolean" ? String(s.default) : undefined;
220
+ return { ...head, kind: "choice", options, multiple: false, other: false, ...(recommended && { recommended }) };
221
+ }
222
+ if (s.type === "string" || s.type === "number" || s.type === "integer") {
223
+ const hint = s.default !== undefined && s.default !== null && typeof s.default !== "object" ? fill(w.defaultHint, { value: String(s.default) }) : formatHint(s.format, w);
224
+ return { ...head, kind: "text", ...(hint && { placeholder: hint }), ...(!required && { optional: true }) };
225
+ }
226
+ return undefined;
227
+ }
228
+ /* ---------------------------------------------------------------- answers → retry */
229
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
230
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
231
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/;
232
+ function formatOk(format, text) {
233
+ if (format === "email")
234
+ return EMAIL.test(text);
235
+ if (format === "date")
236
+ return DATE.test(text) && !Number.isNaN(Date.parse(text));
237
+ if (format === "date-time")
238
+ return DATE_TIME.test(text) && !Number.isNaN(Date.parse(text));
239
+ if (format === "uri") {
240
+ try {
241
+ return Boolean(new URL(text));
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ }
247
+ return true;
248
+ }
249
+ /**
250
+ * The retry's `inputResponses` for the person's replies, checked against each form's schema first (the spec asks
251
+ * clients to validate). Every form, confirmation and link needs a reply; a form is sent only once every required
252
+ * field has a valid answer. `own` holds responses your app made itself, for requests Candor doesn't show (a sampling
253
+ * or roots answer), keyed by the server's ids; anything left is listed in `notSent`.
254
+ */
255
+ export function mcpInputResponses(view, replies, options = {}) {
256
+ const w = { ...mcpInputWords, ...options.words };
257
+ const problems = [];
258
+ // Keys are the server's ids, so the maps have no prototype: "__proto__" or "toString" is just another key.
259
+ const inputResponses = Object.create(null);
260
+ const notSent = [];
261
+ const problem = (key, code, vars = {}, questionId) => problems.push({ key, ...(questionId !== undefined && { questionId }), code, message: fill(w[code], vars) });
262
+ for (const item of view.items) {
263
+ if (item.kind === "unsupported") {
264
+ if (options.own && Object.hasOwn(options.own, item.key))
265
+ inputResponses[item.key] = options.own[item.key];
266
+ else
267
+ notSent.push(item.key);
268
+ continue;
269
+ }
270
+ const reply = replies.find((r) => r.key === item.key);
271
+ if (!reply) {
272
+ problem(item.key, "unanswered");
273
+ continue;
274
+ }
275
+ if (reply.action !== "accept") {
276
+ inputResponses[item.key] = { action: reply.action };
277
+ continue;
278
+ }
279
+ if (item.kind !== "questions") {
280
+ // A confirmation or a link: accepting sends no content. For a link, open it now; the server finds out on retry.
281
+ inputResponses[item.key] = { action: "accept" };
282
+ continue;
283
+ }
284
+ const content = Object.create(null);
285
+ const before = problems.length;
286
+ for (const q of item.questions) {
287
+ const s = item.fields[q.id] ?? {};
288
+ const required = item.required.includes(q.id);
289
+ const a = reply.answers?.find((x) => x.questionId === q.id);
290
+ if (q.kind === "choice") {
291
+ const picked = (a?.choice ?? []).filter((c) => c !== SKIP);
292
+ if (picked.some((c) => !q.options.some((o) => o.id === c))) {
293
+ problem(item.key, "notAnOption", {}, q.id);
294
+ continue;
295
+ }
296
+ if (q.multiple) {
297
+ if (!picked.length && (!required || s.minItems === 0)) {
298
+ if (required)
299
+ content[q.id] = [];
300
+ continue;
301
+ }
302
+ if (!picked.length && s.minItems === undefined)
303
+ problem(item.key, "required", {}, q.id);
304
+ else if (typeof s.minItems === "number" && picked.length < s.minItems)
305
+ problem(item.key, "tooFew", { min: s.minItems }, q.id);
306
+ else if (typeof s.maxItems === "number" && picked.length > s.maxItems)
307
+ problem(item.key, "tooMany", { max: s.maxItems }, q.id);
308
+ else
309
+ content[q.id] = picked;
310
+ continue;
311
+ }
312
+ const value = picked[0];
313
+ if (value === undefined) {
314
+ if (required)
315
+ problem(item.key, "required", {}, q.id);
316
+ continue;
317
+ }
318
+ content[q.id] = s.type === "boolean" ? value === "true" : value;
319
+ continue;
320
+ }
321
+ const text = a?.text?.trim() ?? "";
322
+ if (!text) {
323
+ if (!q.optional)
324
+ problem(item.key, "required", {}, q.id);
325
+ continue;
326
+ }
327
+ if (s.type === "number" || s.type === "integer") {
328
+ const n = Number(text);
329
+ if (!Number.isFinite(n))
330
+ problem(item.key, "notANumber", {}, q.id);
331
+ else if (s.type === "integer" && !Number.isInteger(n))
332
+ problem(item.key, "notAnInteger", {}, q.id);
333
+ else if (typeof s.minimum === "number" && n < s.minimum)
334
+ problem(item.key, "tooSmall", { min: s.minimum }, q.id);
335
+ else if (typeof s.maximum === "number" && n > s.maximum)
336
+ problem(item.key, "tooLarge", { max: s.maximum }, q.id);
337
+ else
338
+ content[q.id] = n;
339
+ continue;
340
+ }
341
+ if (typeof s.minLength === "number" && text.length < s.minLength)
342
+ problem(item.key, "tooShort", { min: s.minLength }, q.id);
343
+ else if (typeof s.maxLength === "number" && text.length > s.maxLength)
344
+ problem(item.key, "tooLong", { max: s.maxLength }, q.id);
345
+ else if (!formatOk(s.format, text))
346
+ problem(item.key, "format", { format: formatHint(s.format, w) ?? String(s.format) }, q.id);
347
+ else
348
+ content[q.id] = text;
349
+ }
350
+ if (problems.length === before)
351
+ inputResponses[item.key] = { action: "accept", content };
352
+ }
353
+ return problems.length ? { ok: false, problems } : { ok: true, inputResponses, notSent };
354
+ }
355
+ /**
356
+ * The params for retrying the original request: its own params (name and arguments for tools/call) with
357
+ * `inputResponses` and the result's `requestState` exactly as received. A `requestState` from an earlier round is
358
+ * removed when this result has none, as the spec requires. Send it as a new request with a new JSON-RPC id.
359
+ */
360
+ export function mcpRetryParams(original, view, inputResponses) {
361
+ const rest = { ...original };
362
+ delete rest.inputResponses;
363
+ delete rest.requestState;
364
+ return {
365
+ ...rest,
366
+ inputResponses,
367
+ ...(view.requestState !== undefined && { requestState: view.requestState }),
368
+ };
369
+ }
package/dist/styles.css CHANGED
@@ -244,6 +244,13 @@
244
244
  * Density: data-density="compact" on <html> (or any container) tightens type for
245
245
  * dense apps: 14px conversation text and 13px body and label text, with a narrower
246
246
  * thread and sidebar.
247
+ *
248
+ * Motion: data-motion="none" | "subtle" | "expressive" on <html> (or any container).
249
+ * Subtle is the default: a change that happened is shown settling (a collapse,
250
+ * a drawn tick, a fade). None turns every animation and transition off, with or
251
+ * without the OS setting. Expressive adds scale to pops and a shake to a locked
252
+ * control that was pressed. Motion reports a state change and never invents one;
253
+ * the OS's reduced-motion setting wins over all three.
247
254
  */
248
255
  @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
249
256
 
@@ -287,6 +294,14 @@
287
294
  --cd-ease-inout: cubic-bezier(0.4, 0, 0.2, 1);
288
295
  --cd-ease-spring: linear(0, 0.4 15%, 0.7 30%, 1 50%, 0.95 65%, 1.02 80%, 1 100%);
289
296
 
297
+ /* How long a state change takes to settle: a colour or a pop, a collapse, a drawn mark or a filled rail. */
298
+ --cd-dur-quick: 120ms;
299
+ --cd-dur-base: 200ms;
300
+ --cd-dur-slow: 360ms;
301
+ /* What data-motion="expressive" turns on. Subtle pops only fade (a scale of 1) and never shakes. */
302
+ --cd-pop-from: 1;
303
+ --cd-motion-shake: none;
304
+
290
305
  /* The message column's width, and the app sidebar's. (Not --sidebar-width: shadcn's SidebarProvider sets that.) */
291
306
  --cd-thread-width: 48rem;
292
307
  --cd-sidebar-width: 16rem;
@@ -325,6 +340,12 @@
325
340
  }
326
341
  }
327
342
 
343
+ /* The motion presets. Subtle needs no attribute; expressive adds scale and a shake; none is below, after the keyframes. */
344
+ [data-motion="expressive"] {
345
+ --cd-pop-from: 0.85;
346
+ --cd-motion-shake: shake var(--cd-dur-slow) var(--cd-ease) both;
347
+ }
348
+
328
349
  @theme inline {
329
350
  --font-sans: var(--cd-font);
330
351
  --font-display: var(--cd-font-display);
@@ -431,6 +452,24 @@
431
452
  --animate-pulse-dot: pulse-dot 1.6s var(--cd-ease) infinite;
432
453
  --animate-rise-in: rise-in 240ms var(--cd-ease) both;
433
454
  --animate-caret: caret 1s steps(2, start) infinite;
455
+
456
+ /*
457
+ * Motion that reports a change. Tailwind has no --duration-* namespace, so use the tokens directly:
458
+ * duration-(--cd-dur-base). A collapse follows Radix's measured height; a pop is a fade (and a scale under
459
+ * expressive); tick-draw draws a mark whose path has pathLength="1"; fill grows a bar from --cd-fill-from; shake is
460
+ * none unless expressive; attention is one ring pulse, attention-wait three slow ones (then still: WCAG 2.2.2 wants moving content to stop).
461
+ */
462
+ --animate-collapse-down: collapse-down var(--cd-dur-base) var(--cd-ease);
463
+ --animate-collapse-up: collapse-up var(--cd-dur-base) var(--cd-ease);
464
+ --animate-pop-in: pop-in var(--cd-dur-base) var(--cd-ease) both;
465
+ --animate-pop-out: pop-out var(--cd-dur-quick) var(--cd-ease) both;
466
+ --animate-tick-draw: tick-draw var(--cd-dur-slow) var(--cd-ease) both;
467
+ --animate-fill: fill var(--cd-dur-slow) var(--cd-ease) both;
468
+ --animate-shake: var(--cd-motion-shake);
469
+ --animate-attention: attention 900ms var(--cd-ease) 1;
470
+ --animate-attention-wait: attention 2.4s var(--cd-ease) 3;
471
+ /* A prompt that was clicked around instead of answered: one attention ring, and a shake when expressive. */
472
+ --animate-nudge: var(--cd-motion-shake), attention 900ms var(--cd-ease) 1;
434
473
  }
435
474
 
436
475
  @keyframes shimmer {
@@ -448,6 +487,48 @@
448
487
  @keyframes caret {
449
488
  to { visibility: hidden; }
450
489
  }
490
+ /* Collapsible content, to and from the height Radix measured. Overflow is hidden only while it moves. */
491
+ @keyframes collapse-down {
492
+ from { height: 0; overflow: hidden; }
493
+ to { height: var(--radix-collapsible-content-height); overflow: hidden; }
494
+ }
495
+ @keyframes collapse-up {
496
+ from { height: var(--radix-collapsible-content-height); overflow: hidden; }
497
+ to { height: 0; overflow: hidden; }
498
+ }
499
+ /* Something added or removed: a fade, and a scale from --cd-pop-from (1 unless expressive). */
500
+ @keyframes pop-in {
501
+ from { opacity: 0; transform: scale(var(--cd-pop-from)); }
502
+ to { opacity: 1; transform: none; }
503
+ }
504
+ @keyframes pop-out {
505
+ from { opacity: 1; transform: none; }
506
+ to { opacity: 0; transform: scale(var(--cd-pop-from)); }
507
+ }
508
+ /* A tick or cross drawn along its path; the path has pathLength="1" and stroke-dasharray 1. */
509
+ @keyframes tick-draw {
510
+ from { stroke-dashoffset: 1; }
511
+ to { stroke-dashoffset: 0; }
512
+ }
513
+ /* A bar that fills to a measured point: along its width by default, or set --cd-fill-from to "1 0" for height. */
514
+ @keyframes fill {
515
+ from { scale: var(--cd-fill-from, 0 1); }
516
+ to { scale: 1; }
517
+ }
518
+ /* A pressed control that can't be used: a small horizontal shake, expressive only. */
519
+ @keyframes shake {
520
+ 0%, 100% { transform: none; }
521
+ 20%, 60% { transform: translateX(-3px); }
522
+ 40%, 80% { transform: translateX(3px); }
523
+ }
524
+ /*
525
+ * A ring that grows and fades, in --cd-attention-color (the text colour unless set): once for something that arrived
526
+ * or a press that asked for attention, repeating while a decision waits.
527
+ */
528
+ @keyframes attention {
529
+ 0%, 100% { outline: 0 solid color-mix(in oklab, var(--cd-attention-color, currentColor) 45%, transparent); }
530
+ 60% { outline: 6px solid transparent; }
531
+ }
451
532
 
452
533
  /*
453
534
  * Syntax colours for Payload's tokens: on under data-syntax="highlight" (on <html> for the whole app, or a Payload's
@@ -468,6 +549,19 @@
468
549
  [data-slot="payload"][data-syntax="highlight"] :is([data-token="number"], [data-token="variable"]) { color: var(--attention-text); }
469
550
  [data-slot="payload"][data-syntax="highlight"] :is([data-token="property"], [data-token="tag"], [data-token="type"]) { color: var(--warning-text); }
470
551
 
552
+ /*
553
+ * data-motion="none": nothing moves, with or without the OS setting. An element leaving with a pop-out (data-leaving)
554
+ * is simply gone. The reduced-motion block below comes last and stays authoritative for every preset.
555
+ */
556
+ [data-motion="none"], [data-motion="none"] *, [data-motion="none"] *::before, [data-motion="none"] *::after {
557
+ animation: none !important;
558
+ transition: none !important;
559
+ scroll-behavior: auto !important;
560
+ }
561
+ [data-motion="none"] [data-leaving] {
562
+ display: none !important;
563
+ }
564
+
471
565
  @media (prefers-reduced-motion: reduce) {
472
566
  *, *::before, *::after {
473
567
  animation-duration: 0.001ms !important;
@@ -2,5 +2,9 @@ import { Collapsible as CollapsiblePrimitive } from "radix-ui";
2
2
  import { type RenderProp } from "../lib/render-prop.js";
3
3
  declare function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>): import("react").JSX.Element;
4
4
  declare function CollapsibleTrigger(allProps: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger> & RenderProp): import("react").JSX.Element;
5
- declare function CollapsibleContent({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>): import("react").JSX.Element;
5
+ /**
6
+ * The content opens and closes to the height Radix measured (--radix-collapsible-content-height), so a change of
7
+ * state is seen settling rather than snapping; data-motion="none" and the OS's reduced-motion setting make it snap.
8
+ */
9
+ declare function CollapsibleContent({ className, ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>): import("react").JSX.Element;
6
10
  export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Collapsible as CollapsiblePrimitive } from "radix-ui";
4
+ import { cn } from "../lib/utils.js";
4
5
  import { withRender } from "../lib/render-prop.js";
5
6
  function Collapsible({ ...props }) {
6
7
  return _jsx(CollapsiblePrimitive.Root, { "data-slot": "collapsible", ...props });
@@ -9,7 +10,11 @@ function CollapsibleTrigger(allProps) {
9
10
  const props = withRender(allProps);
10
11
  return (_jsx(CollapsiblePrimitive.CollapsibleTrigger, { "data-slot": "collapsible-trigger", ...props }));
11
12
  }
12
- function CollapsibleContent({ ...props }) {
13
- return (_jsx(CollapsiblePrimitive.CollapsibleContent, { "data-slot": "collapsible-content", ...props }));
13
+ /**
14
+ * The content opens and closes to the height Radix measured (--radix-collapsible-content-height), so a change of
15
+ * state is seen settling rather than snapping; data-motion="none" and the OS's reduced-motion setting make it snap.
16
+ */
17
+ function CollapsibleContent({ className, ...props }) {
18
+ return (_jsx(CollapsiblePrimitive.CollapsibleContent, { "data-slot": "collapsible-content", className: cn("data-[state=open]:animate-collapse-down data-[state=closed]:animate-collapse-up", className), ...props }));
14
19
  }
15
20
  export { Collapsible, CollapsibleTrigger, CollapsibleContent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candor-ui/react",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Candor UI's free tier as an npm package: React components for agents that do real work, on shadcn/ui and Tailwind 4.",
5
5
  "license": "MIT",
6
6
  "author": "Methodic LLC",