@candor-ui/react 2.3.0 → 2.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 CHANGED
@@ -17,8 +17,8 @@ copy code into their app. If you want to own and edit the source, install with t
17
17
  npm install @candor-ui/react
18
18
  ```
19
19
 
20
- Needs React 19 and Tailwind CSS 4. Radix, lucide-react, class-variance-authority, clsx, tailwind-merge, the Inter font
21
- and tw-animate-css come with it.
20
+ Needs React 19 and Tailwind CSS 4. Radix, lucide-react, class-variance-authority, clsx, tailwind-merge and the Inter
21
+ font come with it.
22
22
 
23
23
  ## Styles (Tailwind 4)
24
24
 
@@ -30,7 +30,7 @@ The components are styled with Tailwind classes and Candor's tokens. Import the
30
30
  ```
31
31
 
32
32
  `styles.css` brings Candor's tokens (colours, type scale, radii, shadows, motion), the light, dark and high-contrast
33
- themes, Inter, tw-animate-css and an `@source` for the package's own files, so Tailwind generates the classes the
33
+ themes, Inter and an `@source` for the package's own files, so Tailwind generates the classes the
34
34
  components use even though it skips `node_modules` when scanning your app. If your setup doesn't follow `@source`
35
35
  from imported CSS, add it yourself, relative to your CSS file:
36
36
 
@@ -5,6 +5,16 @@ import { LockKeyhole } from "lucide-react";
5
5
  import { cn } from "../lib/utils.js";
6
6
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
7
7
  import { motion } from "../lib/motion.js";
8
+ /** The running animation's duration on `el`, in milliseconds (its longest, if there are several); undefined when it can't be read. */
9
+ function animationMs(el) {
10
+ if (!el || typeof getComputedStyle === "undefined")
11
+ return undefined;
12
+ const times = getComputedStyle(el)
13
+ .animationDuration.split(",")
14
+ .map((t) => (t.trim().endsWith("ms") ? Number.parseFloat(t) : Number.parseFloat(t) * 1000))
15
+ .filter((n) => Number.isFinite(n));
16
+ return times.length ? Math.max(...times) : undefined;
17
+ }
8
18
  /** A press on the locked control: the reason is emphasised, and under expressive the control shakes. */
9
19
  const refuseMotion = motion("locked", "refuse", "refuse");
10
20
  const reasonMotion = motion("locked", "reason", "tint", { dur: 200 });
@@ -28,10 +38,14 @@ function Locked({ reason, inline = false, children, className, }) {
28
38
  const [press, setPress] = React.useState(0);
29
39
  const [shaking, setShaking] = React.useState(false);
30
40
  const [held, setHeld] = React.useState(false);
41
+ const box = React.useRef(null);
31
42
  React.useEffect(() => {
32
43
  if (press === 0)
33
44
  return;
34
- const shake = window.setTimeout(() => setShaking(false), 400);
45
+ // The shake ends when its animation does (onAnimationEnd below). This timer is only the fallback for when no
46
+ // animation runs (motion off, or no Candor CSS), set from the duration the page resolved: a brand's longer shake,
47
+ // or the speed token, isn't cut short.
48
+ const shake = window.setTimeout(() => setShaking(false), (animationMs(box.current) ?? 400) + 100);
35
49
  const hold = window.setTimeout(() => setHeld(false), 1800);
36
50
  return () => {
37
51
  window.clearTimeout(shake);
@@ -57,6 +71,9 @@ function Locked({ reason, inline = false, children, className, }) {
57
71
  block(e);
58
72
  },
59
73
  });
60
- return (_jsxs("span", { "data-slot": "locked", "data-pressed": held ? "" : undefined, style: refuseMotion, className: cn("relative inline-flex flex-col items-start gap-1.5", shaking && "cd-refuse", className), children: [inline ? (control) : (_jsxs(Tooltip, { open: shown || held, onOpenChange: setShown, children: [_jsx(TooltipTrigger, { asChild: true, children: control }), _jsx(TooltipContent, { children: reason })] })), _jsxs("span", { id: id, "data-slot": "locked-reason", style: reasonMotion, className: cn("items-center gap-1 text-xs text-muted-foreground cd-tint", inline ? "inline-flex" : "sr-only", held && "text-foreground"), children: [_jsx(LockKeyhole, { "aria-hidden": true, className: "size-3" }), reason] })] }));
74
+ return (_jsxs("span", { ref: box, "data-slot": "locked", onAnimationEnd: (e) => {
75
+ if (e.target === e.currentTarget)
76
+ setShaking(false);
77
+ }, "data-pressed": held ? "" : undefined, style: refuseMotion, className: cn("relative inline-flex flex-col items-start gap-1.5", shaking && "cd-refuse", className), children: [inline ? (control) : (_jsxs(Tooltip, { open: shown || held, onOpenChange: setShown, children: [_jsx(TooltipTrigger, { asChild: true, children: control }), _jsx(TooltipContent, { children: reason })] })), _jsxs("span", { id: id, "data-slot": "locked-reason", style: reasonMotion, className: cn("items-center gap-1 text-xs text-muted-foreground cd-tint", inline ? "inline-flex" : "sr-only", held && "text-foreground"), children: [_jsx(LockKeyhole, { "aria-hidden": true, className: "size-3" }), reason] })] }));
61
78
  }
62
79
  export { Locked };
@@ -80,6 +80,6 @@ function RelativeTime({ date, bare = false, format = "relative", className, }) {
80
80
  // A bare time ("2 days") is read aloud without its direction, so a screen reader hears the whole phrase ("2 days ago")
81
81
  // while the page keeps the short one. "always" keeps the number ("1 day ago", not "yesterday"), so the spoken words match the shown ones.
82
82
  const spoken = bare && format === "relative" && current !== null ? formatRelative(d, new Date(current), { locale: fmt.tag, justNow: t("justNow"), numeric: "always" }) : text;
83
- return (_jsx("time", { dateTime: d.toISOString(), title: current === null ? undefined : fmt.date(d, "datetime"), className: className, children: spoken === text ? (text) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: text }), _jsx("span", { className: "sr-only", children: spoken })] })) }));
83
+ return (_jsx("time", { "data-slot": "relative-time", dateTime: d.toISOString(), title: current === null ? undefined : fmt.date(d, "datetime"), className: className, children: spoken === text ? (text) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: text }), _jsx("span", { className: "sr-only", children: spoken })] })) }));
84
84
  }
85
85
  export { RelativeTime };
@@ -22,13 +22,18 @@ export declare function fileIcon(a: Pick<Attachment, "name" | "mediaType">): Luc
22
22
  /**
23
23
  * A file waiting to be sent, as a chip in the composer: its name, then upload progress, an error, or its size, and a
24
24
  * remove button. Images show their preview when the app gives one.
25
+ *
26
+ * @motion fill:progress The upload bar under the chip moves from the old reported fraction to the new one as the service
27
+ * reports it. It is drawn at its first value in place, never grown from empty, and a file with no progress draws no bar.
25
28
  */
26
- declare function AttachmentChip({ attachment: a, onRemove, className, }: {
29
+ declare function AttachmentChip({ attachment: a, onRemove, className, style, }: {
27
30
  /** The file, with progress (0–1) while uploading or an error if it failed. */
28
31
  attachment: Attachment;
29
32
  /** Shows a remove button. */
30
33
  onRemove?: ((id: string) => void) | undefined;
31
34
  className?: string | undefined;
35
+ /** Inline style for the chip's outer element, such as a motion chain from `motion()`. */
36
+ style?: React.CSSProperties | undefined;
32
37
  }): React.JSX.Element;
33
38
  /**
34
39
  * A sent file, as a tile in a message: an image preview when the app supplied one, else an icon for its type, with
@@ -56,12 +56,15 @@ const thumbnail = (a) => (a.previewUrl && (a.mediaType ?? "").startsWith("image/
56
56
  /**
57
57
  * A file waiting to be sent, as a chip in the composer: its name, then upload progress, an error, or its size, and a
58
58
  * remove button. Images show their preview when the app gives one.
59
+ *
60
+ * @motion fill:progress The upload bar under the chip moves from the old reported fraction to the new one as the service
61
+ * reports it. It is drawn at its first value in place, never grown from empty, and a file with no progress draws no bar.
59
62
  */
60
- function AttachmentChip({ attachment: a, onRemove, className, }) {
63
+ function AttachmentChip({ attachment: a, onRemove, className, style, }) {
61
64
  const t = useStrings("attachmentPreview", attachmentPreviewStrings);
62
65
  const fmt = useFormat();
63
66
  const thumb = thumbnail(a);
64
- return (_jsxs("span", { "data-slot": "attachment-chip", className: cn("relative inline-flex max-w-56 items-center gap-1.5 overflow-hidden rounded-md border bg-card py-1 pe-1 ps-2 text-xs", a.error && "border-destructive/50", className), children: [thumb ? _jsx("img", { src: thumb, alt: "", className: "size-4 shrink-0 rounded-xs object-cover" }) : _jsx(Paperclip, { "aria-hidden": true, className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: a.name }), _jsx("span", { className: cn("shrink-0 tabular-nums", a.error ? "text-destructive-text" : "text-faint-foreground"), children: a.error ? a.error : a.progress !== undefined ? fmt.number(Math.round(a.progress * 100) / 100, { style: "percent" }) : formatFileSize(a.size, fmt.number, t) }), onRemove && (_jsx("button", { type: "button", "aria-label": t("remove", { name: a.name }), onClick: () => onRemove(a.id), className: "relative after:absolute after:-inset-1 rounded-sm p-0.5 text-faint-foreground hover:bg-accent hover:text-foreground", children: _jsx(X, { className: "size-3" }) })), a.progress !== undefined && !a.error && _jsx("span", { "aria-hidden": true, className: "absolute bottom-0 start-0 h-0.5 bg-primary cd-fill", style: { ...progressMotion, width: `${a.progress * 100}%` } })] }));
67
+ return (_jsxs("span", { "data-slot": "attachment-chip", style: style, className: cn("relative inline-flex max-w-56 items-center gap-1.5 overflow-hidden rounded-md border bg-card py-1 pe-1 ps-2 text-xs", a.error && "border-destructive/50", className), children: [thumb ? _jsx("img", { src: thumb, alt: "", className: "size-4 shrink-0 rounded-xs object-cover" }) : _jsx(Paperclip, { "aria-hidden": true, className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: a.name }), _jsx("span", { className: cn("shrink-0 tabular-nums", a.error ? "text-destructive-text" : "text-faint-foreground"), children: a.error ? a.error : a.progress !== undefined ? fmt.number(Math.round(a.progress * 100) / 100, { style: "percent" }) : formatFileSize(a.size, fmt.number, t) }), onRemove && (_jsx("button", { type: "button", "aria-label": t("remove", { name: a.name }), onClick: () => onRemove(a.id), className: "relative after:absolute after:-inset-1 rounded-sm p-0.5 text-faint-foreground hover:bg-accent hover:text-foreground", children: _jsx(X, { className: "size-3" }) })), a.progress !== undefined && !a.error && _jsx("span", { "aria-hidden": true, className: "absolute bottom-0 start-0 h-0.5 bg-primary cd-fill", style: { ...progressMotion, width: `${a.progress * 100}%` } })] }));
65
68
  }
66
69
  /**
67
70
  * A sent file, as a tile in a message: an image preview when the app supplied one, else an icon for its type, with
@@ -46,13 +46,13 @@ function Card({ name, props, summary, }) {
46
46
  const cards = React.useContext(Registry);
47
47
  const def = cards[name];
48
48
  if (!def) {
49
- return (_jsxs("div", { "data-slot": "card", "data-card": name, "data-state": "unknown", className: "flex flex-col gap-1.5 rounded-lg border border-dashed px-3 py-2.5", children: [_jsxs("p", { className: "flex items-center gap-1.5 text-sm text-muted-foreground", children: [_jsx(LayoutTemplate, { "aria-hidden": true, className: "size-3.5" }), " ", t("unknown", { name })] }), _jsx(Summary, { text: summary })] }));
49
+ return (_jsxs("div", { "data-slot": "card-registry", "data-card": name, "data-state": "unknown", className: "flex flex-col gap-1.5 rounded-lg border border-dashed px-3 py-2.5", children: [_jsxs("p", { className: "flex items-center gap-1.5 text-sm text-muted-foreground", children: [_jsx(LayoutTemplate, { "aria-hidden": true, className: "size-3.5" }), " ", t("unknown", { name })] }), _jsx(Summary, { text: summary })] }));
50
50
  }
51
51
  const problem = def.check(props);
52
52
  if (problem) {
53
- return (_jsxs("div", { "data-slot": "card", "data-card": name, "data-state": "invalid", className: "flex flex-col gap-1.5", children: [_jsx(DegradedPanel, { reason: t("invalid", { label: def.label.toLowerCase(), problem }) }), _jsx(Summary, { text: summary })] }));
53
+ return (_jsxs("div", { "data-slot": "card-registry", "data-card": name, "data-state": "invalid", className: "flex flex-col gap-1.5", children: [_jsx(DegradedPanel, { reason: t("invalid", { label: def.label.toLowerCase(), problem }) }), _jsx(Summary, { text: summary })] }));
54
54
  }
55
- return (_jsx("div", { "data-slot": "card", "data-card": name, "data-state": "ready", children: _jsx(CardBoundary, { fallback: _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(DegradedPanel, { reason: t("failed", { label: def.label.toLowerCase() }) }), _jsx(Summary, { text: summary })] }), children: def.render(props) }) }));
55
+ return (_jsx("div", { "data-slot": "card-registry", "data-card": name, "data-state": "ready", children: _jsx(CardBoundary, { fallback: _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(DegradedPanel, { reason: t("failed", { label: def.label.toLowerCase() }) }), _jsx(Summary, { text: summary })] }), children: def.render(props) }) }));
56
56
  }
57
57
  /* ---------------------------------------------------------------- small checking helpers */
58
58
  export const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -52,9 +52,9 @@ function SourceDetail({ source, }) {
52
52
  function Citation({ source, marker, }) {
53
53
  const t = useStrings("citation", citationStrings);
54
54
  if (!source) {
55
- return _jsxs("sup", { className: "text-faint-foreground", children: ["[", marker ?? "?", "]"] });
55
+ return _jsxs("sup", { "data-slot": "citation", className: "text-faint-foreground", children: ["[", marker ?? "?", "]"] });
56
56
  }
57
- return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("sourceLabel", { marker: source.marker, label: source.label }), className: cn("mx-0.5 inline-flex h-4 min-w-4 -translate-y-px items-center justify-center rounded-sm px-1 align-middle text-2xs font-semibold tabular-nums cd-tint outline-none focus-visible:ring-[3px] focus-visible:ring-ring/80", source.provisional
57
+ return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", "data-slot": "citation", "aria-label": t("sourceLabel", { marker: source.marker, label: source.label }), className: cn("mx-0.5 inline-flex h-4 min-w-4 -translate-y-px items-center justify-center rounded-sm px-1 align-middle text-2xs font-semibold tabular-nums cd-tint outline-none focus-visible:ring-[3px] focus-visible:ring-ring/80", source.provisional
58
58
  ? "border border-dashed border-provisional/60 text-provisional hover:bg-muted"
59
59
  : "bg-accent-weak text-accent-text hover:bg-primary hover:text-primary-foreground"), children: source.marker }) }), _jsx(PopoverContent, { align: "start", className: "w-80 max-w-[calc(100vw-2rem)] p-3", children: _jsx(SourceDetail, { source: source }) })] }));
60
60
  }
@@ -218,7 +218,7 @@ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, o
218
218
  e.preventDefault();
219
219
  choose(item);
220
220
  }, onMouseMove: () => setActive(i), className: "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm aria-selected:bg-accent", children: item.type === "command" ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "font-mono text-xs text-accent-text", children: ["/", item.command.name] }), item.command.args && _jsx("span", { className: "font-mono text-xs text-faint-foreground", children: item.command.args }), _jsx("span", { className: "ms-auto truncate text-xs text-muted-foreground", children: item.command.description })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground", children: Icon ? _jsx(Icon, { className: "size-3.5" }) : _jsx("span", { className: "text-xs", children: "@" }) }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate", children: item.option.label }), (item.option.description ?? item.option.entity?.name) !== item.option.label && (item.option.description ?? item.option.entity?.name) && (_jsx("span", { className: "block truncate font-mono text-2xs text-faint-foreground", children: item.option.description ?? item.option.entity?.name }))] })] })) })] }, item.type === "command" ? item.command.id : item.option.id));
221
- }) })), _jsxs("div", { className: cn("rounded-2xl border bg-card shadow-raised cd-tint focus-within:border-border-strong focus-within:shadow-float", locked && "bg-muted shadow-none"), children: [(references.length > 0 || attachments.length > 0 || referenceMotion.leaving.length > 0 || attachmentMotion.leaving.length > 0) && (_jsxs("div", { className: "flex flex-wrap gap-1.5 px-3 pt-3", children: [references.map((r) => (_jsx("span", { "data-slot": "composer-chip", style: chipMotion, className: cn("inline-flex max-w-full min-w-0", referenceArrived(referenceKey(r)) && "cd-pop"), children: _jsx(EntityChip, { entity: r, onRemove: (e) => onReferencesChange?.(references.filter((x) => x !== e)) }) }, referenceKey(r)))), referenceMotion.leaving.map((r) => (_jsx(LeavingChip, { onDone: () => referenceMotion.left(r), children: _jsx(EntityChip, { entity: r }) }, `leaving:${referenceKey(r)}`))), attachments.map((a) => (_jsx("span", { "data-slot": "composer-chip", style: chipMotion, className: cn("inline-flex max-w-full min-w-0", attachmentArrived(a.id) && "cd-pop"), children: _jsx(AttachmentChip, { attachment: a, onRemove: onRemoveAttachment }) }, a.id))), attachmentMotion.leaving.map((a) => (_jsx(LeavingChip, { onDone: () => attachmentMotion.left(a), children: _jsx(AttachmentChip, { attachment: a }) }, `leaving:${a.id}`)))] })), _jsx("label", { htmlFor: id, className: "sr-only", children: label }), _jsx("textarea", { ref: textarea, id: id, rows: 1, value: value, disabled: Boolean(locked), placeholder: locked ? undefined : placeholder, onChange: (e) => {
221
+ }) })), _jsxs("div", { className: cn("rounded-2xl border bg-card shadow-raised cd-tint focus-within:border-border-strong focus-within:shadow-float", locked && "bg-muted shadow-none"), children: [(references.length > 0 || attachments.length > 0 || referenceMotion.leaving.length > 0 || attachmentMotion.leaving.length > 0) && (_jsxs("div", { className: "flex flex-wrap gap-1.5 px-3 pt-3", children: [references.map((r) => (_jsx(EntityChip, { entity: r, onRemove: (e) => onReferencesChange?.(references.filter((x) => x !== e)), style: chipMotion, className: cn(referenceArrived(referenceKey(r)) && "cd-pop") }, referenceKey(r)))), referenceMotion.leaving.map((r) => (_jsx(LeavingChip, { onDone: () => referenceMotion.left(r), children: _jsx(EntityChip, { entity: r }) }, `leaving:${referenceKey(r)}`))), attachments.map((a) => (_jsx(AttachmentChip, { attachment: a, onRemove: onRemoveAttachment, style: chipMotion, className: cn(attachmentArrived(a.id) && "cd-pop") }, a.id))), attachmentMotion.leaving.map((a) => (_jsx(LeavingChip, { onDone: () => attachmentMotion.left(a), children: _jsx(AttachmentChip, { attachment: a }) }, `leaving:${a.id}`)))] })), _jsx("label", { htmlFor: id, className: "sr-only", children: label }), _jsx("textarea", { ref: textarea, id: id, rows: 1, value: value, disabled: Boolean(locked), placeholder: locked ? undefined : placeholder, onChange: (e) => {
222
222
  onChange(e.target.value);
223
223
  requestAnimationFrame(syncTrigger);
224
224
  }, onKeyDown: onKeyDown, onKeyUp: (e) => {
@@ -15,6 +15,11 @@ export declare const contextMeterStrings: {
15
15
  * Counts are the service's own token counts; the ring warns past 80%. When a
16
16
  * new count arrives the ring sweeps to it and changes colour at the warning
17
17
  * line; it draws its first value in place, with no sweep from zero.
18
+ *
19
+ * @motion fill:fill The ring moves from the old measured value to the new one when the count changes while the meter is
20
+ * on screen, and changes colour at the warning line. The first value is drawn in place, never swept from empty.
21
+ * @motion value:value The percentage beside the ring crossfades to the new figure in the quick duration when the count
22
+ * changes while the meter is on screen; it never counts through values that were not measured.
18
23
  */
19
24
  declare function ContextMeter({ segments, limit, className, }: {
20
25
  /** What fills the context window, by kind, in tokens. */
@@ -4,9 +4,13 @@ import * as React from "react";
4
4
  import { cn } from "../lib/utils.js";
5
5
  import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
6
6
  import { useFormat, useStrings } from "../blocks/locale.js";
7
- import { motion } from "../lib/motion.js";
7
+ import { motion, useSettled } from "../lib/motion.js";
8
8
  /** The ring moving to a new measured value, after the first one was drawn in place. */
9
9
  const fillMotion = motion("context-meter", "fill", "fill");
10
+ /** The percentage changing in place. */
11
+ const valueMotion = motion("context-meter", "value", "value");
12
+ /** Every new figure counts as a change worth showing. */
13
+ const changed = { settled: () => true, over: () => false };
10
14
  /** ContextMeter's fixed words; override them under `contextMeter` in LocaleProvider's `strings`. */
11
15
  export const contextMeterStrings = {
12
16
  /** A token count in thousands; `{n}` is already formatted. */
@@ -36,6 +40,11 @@ function k(n, format, thousands) {
36
40
  * Counts are the service's own token counts; the ring warns past 80%. When a
37
41
  * new count arrives the ring sweeps to it and changes colour at the warning
38
42
  * line; it draws its first value in place, with no sweep from zero.
43
+ *
44
+ * @motion fill:fill The ring moves from the old measured value to the new one when the count changes while the meter is
45
+ * on screen, and changes colour at the warning line. The first value is drawn in place, never swept from empty.
46
+ * @motion value:value The percentage beside the ring crossfades to the new figure in the quick duration when the count
47
+ * changes while the meter is on screen; it never counts through values that were not measured.
39
48
  */
40
49
  function ContextMeter({ segments, limit, className, }) {
41
50
  const t = useStrings("contextMeter", contextMeterStrings);
@@ -49,7 +58,9 @@ function ContextMeter({ segments, limit, className, }) {
49
58
  // The sweep is a transition, so it runs only for a value that changed after the first one was drawn.
50
59
  const [drawn, setDrawn] = React.useState(false);
51
60
  React.useEffect(() => setDrawn(true), []);
52
- return (_jsxs(Popover, { children: [_jsxs(PopoverTrigger, { "aria-label": t("label", { used: tokens(used), limit: tokens(limit) }), "data-warn": warn ? "" : undefined, className: cn("inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-2xs tabular-nums outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80", warn ? "text-warning-text" : "text-muted-foreground", className), children: [_jsxs("svg", { viewBox: "0 0 18 18", className: "size-4 -rotate-90", "aria-hidden": true, children: [_jsx("circle", { cx: "9", cy: "9", r: r, fill: "none", stroke: "var(--muted)", strokeWidth: "2.5" }), _jsx("circle", { "data-slot": "context-meter-ring", cx: "9", cy: "9", r: r, fill: "none", stroke: warn ? "var(--warning)" : "var(--primary)", strokeWidth: "2.5", strokeDasharray: `${pct * c} ${c}`, strokeLinecap: "round", style: fillMotion, className: cn(drawn && "cd-fill") })] }), fmt.number(Math.round(pct * 100) / 100, { style: "percent" })] }), _jsxs(PopoverContent, { align: "start", className: "w-72 p-3", children: [_jsxs("p", { className: "flex items-baseline justify-between text-sm font-medium", children: [t("context"), " ", _jsx("span", { className: "text-xs font-normal text-muted-foreground tabular-nums", children: t("usage", { used: tokens(used), limit: tokens(limit) }) })] }), _jsx("div", { "aria-hidden": true, className: "mt-2 flex h-2 overflow-hidden rounded-full bg-muted", children: segments.map((s) => (_jsx("span", { style: { width: `${(s.tokens / limit) * 100}%`, background: kindColor[s.kind] } }, s.label))) }), _jsx("ul", { className: "mt-3 flex flex-col gap-1.5", children: [...segments]
61
+ const percent = fmt.number(Math.round(pct * 100) / 100, { style: "percent" });
62
+ const percentChanges = useSettled(percent, changed);
63
+ return (_jsxs(Popover, { children: [_jsxs(PopoverTrigger, { "data-slot": "context-meter", "aria-label": t("label", { used: tokens(used), limit: tokens(limit) }), "data-warn": warn ? "" : undefined, className: cn("inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-2xs tabular-nums outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80", warn ? "text-warning-text" : "text-muted-foreground", className), children: [_jsxs("svg", { viewBox: "0 0 18 18", className: "size-4 -rotate-90", "aria-hidden": true, children: [_jsx("circle", { cx: "9", cy: "9", r: r, fill: "none", stroke: "var(--muted)", strokeWidth: "2.5" }), _jsx("circle", { "data-slot": "context-meter-ring", cx: "9", cy: "9", r: r, fill: "none", stroke: warn ? "var(--warning)" : "var(--primary)", strokeWidth: "2.5", strokeDasharray: `${pct * c} ${c}`, strokeLinecap: "round", style: fillMotion, className: cn(drawn && "cd-fill") })] }), percentChanges > 0 ? (_jsx("span", { style: valueMotion, className: "cd-value", children: percent }, percentChanges)) : (percent)] }), _jsxs(PopoverContent, { align: "start", className: "w-72 p-3", children: [_jsxs("p", { className: "flex items-baseline justify-between text-sm font-medium", children: [t("context"), " ", _jsx("span", { className: "text-xs font-normal text-muted-foreground tabular-nums", children: t("usage", { used: tokens(used), limit: tokens(limit) }) })] }), _jsx("div", { "aria-hidden": true, className: "mt-2 flex h-2 overflow-hidden rounded-full bg-muted", children: segments.map((s) => (_jsx("span", { style: { width: `${(s.tokens / limit) * 100}%`, background: kindColor[s.kind] } }, s.label))) }), _jsx("ul", { className: "mt-3 flex flex-col gap-1.5", children: [...segments]
53
64
  .sort((a, b) => b.tokens - a.tokens)
54
65
  .map((s) => (_jsxs("li", { className: "flex items-center gap-2 text-xs", children: [_jsx("span", { "aria-hidden": true, className: "size-2 rounded-xs", style: { background: kindColor[s.kind] } }), _jsx("span", { className: "flex-1", children: s.label }), _jsx("span", { className: "text-muted-foreground tabular-nums", children: tokens(s.tokens) })] }, s.label))) }), warn && _jsx("p", { className: "mt-3 text-xs text-warning-text", children: t("nearlyFull") })] })] }));
55
66
  }
@@ -44,7 +44,7 @@ declare function useEntityKinds(): (kind: string) => EntityKindInfo;
44
44
  * identifier is the label unless a friendlier one is given; then the
45
45
  * identifier is on hover and for screen readers.
46
46
  */
47
- declare function EntityChip({ entity, onOpen, onRemove, className, }: {
47
+ declare function EntityChip({ entity, onOpen, onRemove, className, style, }: {
48
48
  /** The thing to show: its kind, identifier and optional label. */
49
49
  entity: EntityRef;
50
50
  /** Makes the chip a button that opens the thing. */
@@ -52,5 +52,7 @@ declare function EntityChip({ entity, onOpen, onRemove, className, }: {
52
52
  /** Adds a remove button, e.g. in the composer. */
53
53
  onRemove?: ((entity: EntityRef) => void) | undefined;
54
54
  className?: string | undefined;
55
+ /** Inline style for the chip's outer element, such as a motion chain from `motion()`. */
56
+ style?: React.CSSProperties | undefined;
55
57
  }): React.JSX.Element;
56
58
  export { EntityChip, EntityKindsProvider, useEntityKinds };
@@ -72,7 +72,7 @@ function useEntityKinds() {
72
72
  * identifier is the label unless a friendlier one is given; then the
73
73
  * identifier is on hover and for screen readers.
74
74
  */
75
- function EntityChip({ entity, onOpen, onRemove, className, }) {
75
+ function EntityChip({ entity, onOpen, onRemove, className, style, }) {
76
76
  const { Icon, noun } = useEntityKinds()(entity.kind);
77
77
  const t = useStrings("entityChip", entityChipStrings);
78
78
  const shown = entity.label ?? entity.name;
@@ -81,6 +81,6 @@ function EntityChip({ entity, onOpen, onRemove, className, }) {
81
81
  : t("title", { noun, name: entity.name });
82
82
  const body = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": true, className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: shown }), _jsxs("span", { className: "sr-only", children: [" (", title, ")"] })] }));
83
83
  const base = "inline-flex max-w-full items-center gap-1 rounded-sm border bg-card px-1.5 py-px align-baseline font-mono text-[0.92em] leading-[1.35] text-foreground";
84
- return (_jsxs("span", { "data-slot": "entity-chip", "data-kind": entity.kind, title: title, className: cn("inline-flex max-w-full min-w-0 items-center sm:max-w-64", className), children: [onOpen ? (_jsx("button", { type: "button", onClick: () => onOpen(entity), className: cn(base, "cursor-pointer cd-tint hover:border-border-strong hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80 outline-none"), children: body })) : (_jsx("span", { className: base, children: body })), onRemove && (_jsx("button", { type: "button", "aria-label": t("remove", { name: shown }), onClick: () => onRemove(entity), className: "relative after:absolute after:-inset-1 -ms-px rounded-e-sm border border-s-0 bg-card px-1 py-px text-faint-foreground hover:text-foreground", children: "\u00D7" }))] }));
84
+ return (_jsxs("span", { "data-slot": "entity-chip", "data-kind": entity.kind, title: title, style: style, className: cn("inline-flex max-w-full min-w-0 items-center sm:max-w-64", className), children: [onOpen ? (_jsx("button", { type: "button", onClick: () => onOpen(entity), className: cn(base, "cursor-pointer cd-tint hover:border-border-strong hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80 outline-none"), children: body })) : (_jsx("span", { className: base, children: body })), onRemove && (_jsx("button", { type: "button", "aria-label": t("remove", { name: shown }), onClick: () => onRemove(entity), className: "relative after:absolute after:-inset-1 -ms-px rounded-e-sm border border-s-0 bg-card px-1 py-px text-faint-foreground hover:text-foreground", children: "\u00D7" }))] }));
85
85
  }
86
86
  export { EntityChip, EntityKindsProvider, useEntityKinds };
@@ -113,6 +113,11 @@ export type PermissionAnswer = {
113
113
  * nothing else is ever counted as a denial. While the decision waits, its shield pulses slowly; a press on inert
114
114
  * space outside it (the gesture that would dismiss a dialog) nudges it instead, so the person sees it is still
115
115
  * waiting. Both stop once the answer is being sent.
116
+ *
117
+ * @motion attention:waiting While the decision waits, a ring pulses out from the shield three times and then is still.
118
+ * It stops once the answer is being sent, and under calm it is one ring that fades.
119
+ * @motion attention:nudge A press on inert space outside the prompt rings it once, so the person sees it is still waiting.
120
+ * @motion refuse:nudge Under expressive the same press also shakes the prompt a little; nothing is dismissed.
116
121
  */
117
122
  declare function PermissionPrompt({ request, mode, suggestions, editable, onAllow, onDeny, busy, className, }: {
118
123
  /** The waiting request. */
@@ -7,6 +7,16 @@ import { useStrings } from "../blocks/locale.js";
7
7
  import { Payload } from "./payload.js";
8
8
  import { Button } from "../ui/button.js";
9
9
  import { motion } from "../lib/motion.js";
10
+ /** How long the animations running on `el` take in all, in milliseconds (the longest of duration × iteration count, capped at 6 s); undefined when it can't be read. */
11
+ function animationMs(el) {
12
+ if (!el || typeof getComputedStyle === "undefined")
13
+ return undefined;
14
+ const style = getComputedStyle(el);
15
+ const durations = style.animationDuration.split(",").map((t) => (t.trim().endsWith("ms") ? Number.parseFloat(t) : Number.parseFloat(t) * 1000));
16
+ const counts = style.animationIterationCount.split(",").map((c) => (c.trim() === "infinite" ? Number.POSITIVE_INFINITY : Number.parseFloat(c)));
17
+ const totals = durations.map((d, i) => d * (counts[i % counts.length] || 1)).filter((n) => Number.isFinite(n) || n === Number.POSITIVE_INFINITY);
18
+ return totals.length ? Math.min(6000, Math.max(...totals)) : undefined;
19
+ }
10
20
  /** Three pulses of 1.6 s while the decision waits (4.8 s, then still), shortened from 2.2's 7.2 s. */
11
21
  const waitingMotion = motion("permission-prompt", "waiting", "attention", { dur: 1600, amp: "3" });
12
22
  /** A press around the prompt instead of on it: one ring, and the refuse shake under expressive. */
@@ -88,6 +98,11 @@ function parseObject(text) {
88
98
  * nothing else is ever counted as a denial. While the decision waits, its shield pulses slowly; a press on inert
89
99
  * space outside it (the gesture that would dismiss a dialog) nudges it instead, so the person sees it is still
90
100
  * waiting. Both stop once the answer is being sent.
101
+ *
102
+ * @motion attention:waiting While the decision waits, a ring pulses out from the shield three times and then is still.
103
+ * It stops once the answer is being sent, and under calm it is one ring that fades.
104
+ * @motion attention:nudge A press on inert space outside the prompt rings it once, so the person sees it is still waiting.
105
+ * @motion refuse:nudge Under expressive the same press also shakes the prompt a little; nothing is dismissed.
91
106
  */
92
107
  function PermissionPrompt({ request, mode, suggestions, editable = false, onAllow, onDeny, busy = false, className, }) {
93
108
  // Keyed by the request, so an edit, a ticked rule or an answer never carries over to the next request.
@@ -116,6 +131,7 @@ function PromptBody({ request, mode, suggestions, editable, onAllow, onDeny, bus
116
131
  // A press on inert space outside every prompt, while it waits, nudges it rather than dismissing anything.
117
132
  const section = React.useRef(null);
118
133
  const [nudged, setNudged] = React.useState(false);
134
+ const ended = React.useRef(new Set());
119
135
  React.useEffect(() => {
120
136
  if (busy)
121
137
  return;
@@ -136,7 +152,11 @@ function PromptBody({ request, mode, suggestions, editable, onAllow, onDeny, bus
136
152
  React.useEffect(() => {
137
153
  if (!nudged)
138
154
  return;
139
- const timer = window.setTimeout(() => setNudged(false), 1000);
155
+ // The nudge ends when its animations do (onAnimationEnd on the section). This timer is only the fallback for when
156
+ // none runs (motion off, or no Candor CSS), set from the duration and count the page resolved, so a brand's longer
157
+ // ring or shake isn't cut short.
158
+ ended.current = new Set();
159
+ const timer = window.setTimeout(() => setNudged(false), (animationMs(section.current) ?? 1200) + 100);
140
160
  return () => window.clearTimeout(timer);
141
161
  }, [nudged]);
142
162
  const word = (s) => {
@@ -171,7 +191,16 @@ function PromptBody({ request, mode, suggestions, editable, onAllow, onDeny, bus
171
191
  return;
172
192
  onAllow({ ...(edited && changedKeys.length && { updatedInput: edited }), ...(ticked.length && { alwaysAllow: ticked }) });
173
193
  };
174
- return (_jsxs("section", { ref: section, "data-slot": "permission-prompt", "data-waiting": busy ? undefined : "", "data-nudged": nudged ? "" : undefined, "aria-label": t("label", { title: questions ? t("questions") : title }), style: nudgeMotion, className: cn("overflow-hidden rounded-xl border border-warning/50 bg-card text-sm shadow-xs [--cd-attention-color:var(--warning)]", nudged && "cd-nudge", className), children: [_jsxs("header", { className: "flex items-start gap-2.5 bg-warning-weak/50 px-3.5 py-2.5", children: [_jsx("span", { "aria-hidden": true, style: waitingMotion, className: cn("mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-warning-text", !busy && "cd-attention"), children: _jsx(ShieldQuestion, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-semibold leading-snug", children: questions ? t("questions") : title }), request.description && _jsx("p", { className: "mt-0.5 text-muted-foreground", children: request.description })] }), _jsx("code", { className: "shrink-0 font-mono text-2xs text-muted-foreground", children: request.tool })] }), _jsxs("dl", { className: "flex flex-col gap-1.5 px-3.5 pt-2.5 text-sm", children: [request.reason && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("why") }), _jsx("dd", { className: "min-w-0", children: request.reason })] })), mode && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("mode") }), _jsx("dd", { "data-slot": "permission-mode", children: modeKeys[mode] ? t(modeKeys[mode]) : _jsx("code", { className: "font-mono text-xs", children: mode }) })] }))] }), (request.blockedPath || request.agentId || request.mcpServer) && (_jsxs("ul", { className: "mt-1.5 flex flex-col gap-0.5 px-3.5 text-xs text-muted-foreground", children: [request.blockedPath && _jsx("li", { children: t("blockedPath", { path: request.blockedPath }) }), request.agentId && _jsx("li", { children: t("subagent") }), request.mcpServer && _jsx("li", { children: t("server", { name: request.mcpServer.name, source: request.mcpServer.source }) })] })), questions ? (_jsx("div", { className: "flex flex-col gap-4 px-3.5 py-3", children: questions.map((q) => {
194
+ return (_jsxs("section", { ref: section, "data-slot": "permission-prompt", "data-waiting": busy ? undefined : "", "data-nudged": nudged ? "" : undefined, onAnimationEnd: (e) => {
195
+ // The nudge runs two animations (the shake and the ring) and ends with the last of them. A child's own
196
+ // animation, or one that isn't Candor's, doesn't end it.
197
+ if (e.target !== e.currentTarget || !e.animationName.startsWith("cd-"))
198
+ return;
199
+ ended.current.add(e.animationName);
200
+ const running = getComputedStyle(e.currentTarget).animationName.split(",").map((n) => n.trim()).filter((n) => n.startsWith("cd-"));
201
+ if (running.every((n) => ended.current.has(n)))
202
+ setNudged(false);
203
+ }, "aria-label": t("label", { title: questions ? t("questions") : title }), style: nudgeMotion, className: cn("overflow-hidden rounded-xl border border-warning/50 bg-card text-sm shadow-xs [--cd-attention-color:var(--warning)]", nudged && "cd-nudge", className), children: [_jsxs("header", { className: "flex items-start gap-2.5 bg-warning-weak/50 px-3.5 py-2.5", children: [_jsx("span", { "aria-hidden": true, style: waitingMotion, className: cn("mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-warning-text", !busy && "cd-attention"), children: _jsx(ShieldQuestion, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-semibold leading-snug", children: questions ? t("questions") : title }), request.description && _jsx("p", { className: "mt-0.5 text-muted-foreground", children: request.description })] }), _jsx("code", { className: "shrink-0 font-mono text-2xs text-muted-foreground", children: request.tool })] }), _jsxs("dl", { className: "flex flex-col gap-1.5 px-3.5 pt-2.5 text-sm", children: [request.reason && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("why") }), _jsx("dd", { className: "min-w-0", children: request.reason })] })), mode && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("mode") }), _jsx("dd", { "data-slot": "permission-mode", children: modeKeys[mode] ? t(modeKeys[mode]) : _jsx("code", { className: "font-mono text-xs", children: mode }) })] }))] }), (request.blockedPath || request.agentId || request.mcpServer) && (_jsxs("ul", { className: "mt-1.5 flex flex-col gap-0.5 px-3.5 text-xs text-muted-foreground", children: [request.blockedPath && _jsx("li", { children: t("blockedPath", { path: request.blockedPath }) }), request.agentId && _jsx("li", { children: t("subagent") }), request.mcpServer && _jsx("li", { children: t("server", { name: request.mcpServer.name, source: request.mcpServer.source }) })] })), questions ? (_jsx("div", { className: "flex flex-col gap-4 px-3.5 py-3", children: questions.map((q) => {
175
204
  const a = answers[q.id];
176
205
  return (_jsxs("fieldset", { className: "flex flex-col gap-1.5", children: [_jsxs("legend", { className: "mb-1 font-medium", children: [q.tag && _jsx("span", { className: "me-1.5 rounded-xs bg-muted px-1 text-2xs font-medium text-muted-foreground", children: q.tag }), q.prompt] }), q.options.map((o) => (_jsxs("label", { className: "flex min-h-6 items-start gap-2", children: [_jsx("input", { type: q.multiple ? "checkbox" : "radio", name: `${id}-${q.id}`, checked: a?.choice.includes(o.id) ?? false, onChange: () => choose(q, o.id), className: "mt-0.5 size-4 accent-[var(--primary)]" }), _jsxs("span", { children: [o.label, o.detail && _jsx("span", { className: "block text-xs text-muted-foreground", children: o.detail })] })] }, o.id))), q.other && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex min-h-6 items-start gap-2", children: [_jsx("input", { type: q.multiple ? "checkbox" : "radio", name: `${id}-${q.id}`, checked: a?.other ?? false, onChange: () => toggleOther(q), className: "mt-0.5 size-4 accent-[var(--primary)]" }), _jsx("span", { children: t("otherOption") })] }), a?.other && (_jsx("textarea", { "aria-label": t("otherLabel", { question: q.prompt }), rows: 2, value: a.text, onChange: (e) => setAnswers((prev) => ({ ...prev, [q.id]: { ...prev[q.id], text: e.target.value } })), className: "ms-6 block resize-none rounded-md border border-input bg-card px-2.5 py-1.5 text-sm outline-none [field-sizing:content] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30" }))] }))] }, q.id));
177
206
  }) })) : (_jsxs("div", { className: "flex flex-col gap-2 px-3.5 py-3", children: [editing ? (_jsxs(_Fragment, { children: [_jsx("label", { htmlFor: `${id}-edit`, className: "text-xs font-medium text-muted-foreground", children: t("editLabel") }), _jsx("textarea", { id: `${id}-edit`, value: draft, onChange: (e) => setDraft(e.target.value), spellCheck: false, rows: Math.min(12, draft.split("\n").length + 1), "aria-invalid": invalid || undefined, "aria-describedby": `${id}-changes`, className: "block w-full resize-y rounded-md border border-input bg-card px-2.5 py-1.5 font-mono text-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-invalid:border-destructive" }), _jsx("div", { id: `${id}-changes`, role: "status", className: "text-xs", children: invalid ? (_jsx("p", { className: "text-destructive-text", children: t("editInvalid") })) : changedKeys.length ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("p", { className: "text-muted-foreground", children: t("changed") }), _jsx("ul", { className: "flex flex-col gap-1", children: changedKeys.map((k) => (_jsxs("li", { className: "grid gap-0.5 font-mono", children: [_jsx("span", { className: "text-muted-foreground", children: k }), k in proposed && _jsx("span", { className: "rounded-xs bg-destructive-weak/60 px-1 line-through", children: json(proposed[k]) }), k in edited && _jsx("span", { className: "rounded-xs bg-success-weak/70 px-1", children: json(edited[k]) })] }, k))) })] })) : (_jsx("p", { className: "text-muted-foreground", children: t("unchanged") })) }), _jsx(Button, { size: "sm", variant: "ghost", className: "self-start", onClick: () => (setEditing(false), setDraft(original)), children: t("cancelEdit") })] })) : (_jsxs(_Fragment, { children: [_jsx(Payload, { language: "json", title: t("input"), text: original }), editable && proposed && (_jsxs(Button, { size: "sm", variant: "outline", className: "self-start", onClick: () => setEditing(true), children: [_jsx(Pencil, { "aria-hidden": true }), " ", t("edit")] }))] })), suggestions && suggestions.length > 0 && (_jsxs("fieldset", { className: "mt-1 flex flex-col gap-1 rounded-lg border border-dashed px-3 py-2", children: [_jsx("legend", { className: "px-1 text-xs font-medium", children: t("remember") }), _jsxs("p", { className: "flex items-center gap-1 text-xs text-muted-foreground", children: [_jsx(Info, { "aria-hidden": true, className: "size-3" }), " ", t("rememberHint")] }), suggestions.map((s, i) => {
@@ -9,6 +9,16 @@ import { motion, useMotionAllowed } from "../lib/motion.js";
9
9
  /** The smooth scroll to the latest message. */
10
10
  const scrollMotion = motion("thread", "scroll", "move");
11
11
  /** Jump to latest appearing while the reader is scrolled up: 2.2's 150 ms and 4 px rise. */
12
+ /** How long the animations running on `el` take in all, in milliseconds (the longest of duration × iteration count, capped at 6 s); undefined when it can't be read. */
13
+ function animationMs(el) {
14
+ if (!el || typeof getComputedStyle === "undefined")
15
+ return undefined;
16
+ const style = getComputedStyle(el);
17
+ const durations = style.animationDuration.split(",").map((t) => (t.trim().endsWith("ms") ? Number.parseFloat(t) : Number.parseFloat(t) * 1000));
18
+ const counts = style.animationIterationCount.split(",").map((c) => (c.trim() === "infinite" ? Number.POSITIVE_INFINITY : Number.parseFloat(c)));
19
+ const totals = durations.map((d, i) => d * (counts[i % counts.length] || 1)).filter((n) => Number.isFinite(n) || n === Number.POSITIVE_INFINITY);
20
+ return totals.length ? Math.min(6000, Math.max(...totals)) : undefined;
21
+ }
12
22
  const jumpMotion = motion("thread", "jump", "arrive", { dur: 150 });
13
23
  /** Its ring when a message arrives while the reader is scrolled up. */
14
24
  const arrivalMotion = motion("thread", "arrival", "attention");
@@ -58,6 +68,7 @@ function Thread({ children, empty, footer, className, contentClassName, virtuali
58
68
  const [arrived, setArrived] = React.useState(false);
59
69
  const arrivedRef = React.useRef(false);
60
70
  const frame = React.useRef(0);
71
+ const jump = React.useRef(null);
61
72
  React.useEffect(() => {
62
73
  arrivedRef.current = arrived;
63
74
  }, [arrived]);
@@ -119,7 +130,10 @@ function Thread({ children, empty, footer, className, contentClassName, virtuali
119
130
  React.useEffect(() => {
120
131
  if (!arrived)
121
132
  return;
122
- const timer = window.setTimeout(() => setArrived(false), 1000);
133
+ // The ring ends when its animation does (onAnimationEnd on the button). This timer is only the fallback for when
134
+ // no animation runs (motion off, or no Candor CSS), set from the duration and count the page resolved, so a brand's
135
+ // longer ring isn't cut short.
136
+ const timer = window.setTimeout(() => setArrived(false), (animationMs(jump.current) ?? 1200) + 100);
123
137
  return () => window.clearTimeout(timer);
124
138
  }, [arrived]);
125
139
  // Open at the anchor once, when it first appears.
@@ -188,7 +202,10 @@ function Thread({ children, empty, footer, className, contentClassName, virtuali
188
202
  }
189
203
  : undefined, onBlur: virtualize ? (e) => !e.currentTarget.contains(e.relatedTarget) && setFocused(null) : undefined, className: "h-full scroll-pb-14 overflow-y-auto overscroll-contain", children: _jsx("div", { ref: content, role: "log", "aria-label": t("messages"), className: cn("mx-auto flex w-full max-w-(--cd-thread-width) flex-col gap-7 px-4 py-6", contentClassName), children: !hasMessages ? (empty) : virtualize ? (_jsx(_Fragment, { children: list.rows.map((row) => "spacer" in row ? (_jsx("div", { "aria-hidden": true, style: { height: row.spacer } }, row.key)) : (_jsx("div", { ref: list.measure, "data-virtual-key": String(keyOf(row.index)), className: "flex flex-col", children: items[row.index] }, keyOf(row.index)))) })) : (children) }) }), !pinned && hasMessages && (
190
204
  // The entrance is the wrapper's, so the pulse on the button never replays it.
191
- _jsx("span", { style: jumpMotion, className: "absolute bottom-3 left-1/2 z-10 -translate-x-1/2 cd-arrive", children: _jsxs("button", { type: "button", "data-slot": "thread-jump", "data-arrived": arrived ? "" : undefined, onClick: () => {
205
+ _jsx("span", { style: jumpMotion, className: "absolute bottom-3 left-1/2 z-10 -translate-x-1/2 cd-arrive", children: _jsxs("button", { type: "button", ref: jump, "data-slot": "thread-jump", onAnimationEnd: (e) => {
206
+ if (e.target === e.currentTarget && e.animationName.startsWith("cd-"))
207
+ setArrived(false);
208
+ }, "data-arrived": arrived ? "" : undefined, onClick: () => {
192
209
  pinnedRef.current = true;
193
210
  setPinned(true);
194
211
  toBottom("smooth");
package/dist/index.d.ts CHANGED
@@ -51,6 +51,7 @@ export * from "./ui/slider.js";
51
51
  export * from "./ui/progress.js";
52
52
  export * from "./ui/alert.js";
53
53
  export * from "./ui/accordion.js";
54
+ export * from "./lib/sound.js";
54
55
  export * from "./lib/utils.js";
55
56
  export { ToolCall } from "./chat/tool-call.js";
56
57
  export type { ToolCall as ToolCallData } from "./lib/chat.js";
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ export * from "./ui/slider.js";
51
51
  export * from "./ui/progress.js";
52
52
  export * from "./ui/alert.js";
53
53
  export * from "./ui/accordion.js";
54
+ export * from "./lib/sound.js";
54
55
  export * from "./lib/utils.js";
55
56
  export { ToolCall } from "./chat/tool-call.js";
56
57
  export { Message } from "./chat/message.js";
@@ -350,6 +350,13 @@ export declare const motionFamilies: {
350
350
  export declare const motionFamilyNames: MotionFamily[];
351
351
  /** A family's variants, by name: `MotionVariant<"mark">` is `"draw" | "pop" | "drop"`. */
352
352
  export type MotionVariant<F extends MotionFamily> = (typeof motionFamilies)[F]["variants"][number];
353
+ /**
354
+ * True when a family has one way of drawing its change (fill, move, press, tint, refuse, attention, live and clock).
355
+ * Its single variant is fixed: there is no `-variant` token to set, and `motion()` writes none, because the family's
356
+ * utility has nothing to choose between. The docs mark such a family "(fixed)" instead of listing a value that does
357
+ * nothing. A later release that adds a second word turns the token on without a new name.
358
+ */
359
+ export declare const motionVariantIsFixed: (family: MotionFamily) => boolean;
353
360
  /**
354
361
  * The CSS value a variant is written as. CSS can only choose a drawing by keyframe name, so a variant's value is its
355
362
  * keyframe's: the word with Candor's prefix, which keeps an app's own `@keyframes rise` from taking over.
@@ -420,7 +427,8 @@ export declare function useSettled<T>(value: T, test: SettledTest<T>): number;
420
427
  export declare function useArrived(keys: readonly string[]): (key: string) => boolean;
421
428
  /**
422
429
  * Items that just left, kept as inert ghosts for the length of their out moment. Each ghost goes when `left(item)` is
423
- * called (from its `onAnimationEnd`), or after `fallbackMs` when nothing animated. An item that comes back while its
430
+ * called (from its `onAnimationEnd`), or after `fallbackMs` when nothing animated. The default, 600 ms, is longer than
431
+ * the longest out moment a brand can set (pop's 300 ms at the slowest speed, 1.5×, is 450 ms). An item that comes back while its
424
432
  * ghost is still leaving takes the ghost's place.
425
433
  */
426
434
  export declare function useLeaving<T>(items: readonly T[], keyOf: (item: T) => string, fallbackMs?: number): {
@@ -436,6 +444,13 @@ export type MotionMode = "full" | "calm" | "none";
436
444
  * panels) are ignored.
437
445
  */
438
446
  export declare function motionMode(el: Element | null | undefined): MotionMode;
447
+ /**
448
+ * `motionMode` for a component that has to react to it: the person's setting where `ref`'s element is, read after
449
+ * mount and again whenever the app changes `data-motion` or the OS setting changes. It starts as `full` for the first
450
+ * render, since there is nothing to read until the element exists. Use it for behaviour (a timer that must stop),
451
+ * not for styling, which the CSS variants already answer.
452
+ */
453
+ export declare function useMotionMode(ref: React.RefObject<Element | null>): MotionMode;
439
454
  /**
440
455
  * The level a moment resolves to on `el`, read from its computed style: the moment's token, its family's, the preset,
441
456
  * then capped by `data-motion`. A page without Candor's CSS reads as subtle.
@@ -179,6 +179,13 @@ export const motionFamilies = {
179
179
  };
180
180
  /** The family names, in the order the docs and the theme builder list them. */
181
181
  export const motionFamilyNames = Object.keys(motionFamilies);
182
+ /**
183
+ * True when a family has one way of drawing its change (fill, move, press, tint, refuse, attention, live and clock).
184
+ * Its single variant is fixed: there is no `-variant` token to set, and `motion()` writes none, because the family's
185
+ * utility has nothing to choose between. The docs mark such a family "(fixed)" instead of listing a value that does
186
+ * nothing. A later release that adds a second word turns the token on without a new name.
187
+ */
188
+ export const motionVariantIsFixed = (family) => motionFamilies[family].variants.length < 2;
182
189
  /**
183
190
  * The CSS value a variant is written as. CSS can only choose a drawing by keyframe name, so a variant's value is its
184
191
  * keyframe's: the word with Candor's prefix, which keeps an app's own `@keyframes rise` from taking over.
@@ -215,7 +222,7 @@ export function motion(item, moment, family, defaults = {}) {
215
222
  style[`${local}-ease`] = `var(${own}-ease, var(${fam}-ease, ${motionEases[defaults.ease ?? spec.ease]}))`;
216
223
  if (spec.amp)
217
224
  style[`${local}-amp`] = `var(${own}-${spec.amp.name}, var(${fam}-${spec.amp.name}, ${defaults.amp ?? spec.amp.default}))`;
218
- if (spec.variants.length > 1) {
225
+ if (!motionVariantIsFixed(family)) {
219
226
  const variant = motionVariantValue(defaults.variant ?? spec.variants[0]);
220
227
  style[`${local}-variant`] = `var(${own}-variant, var(${fam}-variant, ${variant}))`;
221
228
  }
@@ -272,10 +279,11 @@ export function useArrived(keys) {
272
279
  }
273
280
  /**
274
281
  * Items that just left, kept as inert ghosts for the length of their out moment. Each ghost goes when `left(item)` is
275
- * called (from its `onAnimationEnd`), or after `fallbackMs` when nothing animated. An item that comes back while its
282
+ * called (from its `onAnimationEnd`), or after `fallbackMs` when nothing animated. The default, 600 ms, is longer than
283
+ * the longest out moment a brand can set (pop's 300 ms at the slowest speed, 1.5×, is 450 ms). An item that comes back while its
276
284
  * ghost is still leaving takes the ghost's place.
277
285
  */
278
- export function useLeaving(items, keyOf, fallbackMs = 400) {
286
+ export function useLeaving(items, keyOf, fallbackMs = 600) {
279
287
  const [leaving, setLeaving] = React.useState([]);
280
288
  const previous = React.useRef(null);
281
289
  React.useLayoutEffect(() => {
@@ -314,6 +322,28 @@ export function motionMode(el) {
314
322
  return "calm";
315
323
  return "full";
316
324
  }
325
+ /**
326
+ * `motionMode` for a component that has to react to it: the person's setting where `ref`'s element is, read after
327
+ * mount and again whenever the app changes `data-motion` or the OS setting changes. It starts as `full` for the first
328
+ * render, since there is nothing to read until the element exists. Use it for behaviour (a timer that must stop),
329
+ * not for styling, which the CSS variants already answer.
330
+ */
331
+ export function useMotionMode(ref) {
332
+ const [mode, setMode] = React.useState("full");
333
+ React.useEffect(() => {
334
+ const read = () => setMode(motionMode(ref.current));
335
+ read();
336
+ const observer = new MutationObserver(read);
337
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"], subtree: true });
338
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
339
+ query?.addEventListener("change", read);
340
+ return () => {
341
+ observer.disconnect();
342
+ query?.removeEventListener("change", read);
343
+ };
344
+ }, [ref]);
345
+ return mode;
346
+ }
317
347
  const number = (value) => {
318
348
  const n = Number.parseFloat(value);
319
349
  return Number.isFinite(n) ? n : undefined;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The names and the reading half of Candor's sound layer. A brand file names a sound per event
3
+ * (`--cd-sound-needs-you: "/sounds/needs-you.mp3"`); this file knows the five events, reads that token from the
4
+ * page's computed style, and holds the small arithmetic the Pro hooks share. It plays nothing: nothing is fetched
5
+ * until `useAttention` or `sound-setting` plays a sound, and a sound is always off until the person turns it on.
6
+ *
7
+ * Tokens, all optional, all read when a sound is about to play:
8
+ * - `--cd-sound-<event>`: a string, the path of an MP3 hosted by the app. No token, no sound, silently.
9
+ * - `--cd-sound-<event>-ogg`: a string, an Ogg/Opus alternative the browser may prefer.
10
+ * - `--cd-sound-volume`: 0 to 1, how loud every sound is. `--cd-sound-<event>-volume`: 0 to 1, scales that for one event.
11
+ *
12
+ * Safe to import on the server: the document is touched only inside `readSoundToken`.
13
+ */
14
+ /** The five things the app may make a sound for. A closed list: `reply` and `silent` have no `AttentionKind` until 3.0. */
15
+ export type SoundEvent = "needs-you" | "finished" | "failed" | "reply" | "silent";
16
+ /** Every event, in the order the settings control lists them. */
17
+ export declare const soundEvents: readonly SoundEvent[];
18
+ /** The events a sound is offered for once the person turns sound on; `reply` and `silent` stay off until they pick them. */
19
+ export declare const defaultSoundEvents: readonly SoundEvent[];
20
+ /** A sound as the brand names it: where the file is, an optional Ogg alternative, and how loud (0 to 1). */
21
+ export type SoundToken = {
22
+ src: string;
23
+ ogg?: string | undefined;
24
+ volume: number;
25
+ };
26
+ /** The longest a sound may be, in seconds. `theme check` measures what it can read. */
27
+ export declare const maxSoundSeconds = 1.5;
28
+ /** The least time between two plays of the same event, in milliseconds. A burst plays once. */
29
+ export declare const soundCoalesceMs = 3000;
30
+ /** A CSS custom property's value as the text it names: quotes removed, or the inside of `url(…)`. Empty when unset. */
31
+ export declare function soundString(raw: string): string;
32
+ /** How loud `event` is, 0 to 1: the global `--cd-sound-volume` times the event's own, each 1 when unset. 1 on the server. */
33
+ export declare function readSoundVolume(event: SoundEvent, element?: Element | undefined): number;
34
+ /**
35
+ * The brand's sound for `event`, read from the computed style of `element` (the document's root when left out), or
36
+ * `undefined` when the brand names none, or on the server.
37
+ */
38
+ export declare function readSoundToken(event: SoundEvent, element?: Element | undefined): SoundToken | undefined;
39
+ /**
40
+ * Whether a play at `now` is allowed, given when each event last played and when anything last played: the same event
41
+ * waits `soundCoalesceMs` after it played, and any sound in the same instant (a burst) is the one already playing.
42
+ * Pure; the caller keeps the clock.
43
+ */
44
+ export declare function mayPlay(event: SoundEvent, last: Partial<Record<SoundEvent, number>>, now: number, burstMs?: number): boolean;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The names and the reading half of Candor's sound layer. A brand file names a sound per event
3
+ * (`--cd-sound-needs-you: "/sounds/needs-you.mp3"`); this file knows the five events, reads that token from the
4
+ * page's computed style, and holds the small arithmetic the Pro hooks share. It plays nothing: nothing is fetched
5
+ * until `useAttention` or `sound-setting` plays a sound, and a sound is always off until the person turns it on.
6
+ *
7
+ * Tokens, all optional, all read when a sound is about to play:
8
+ * - `--cd-sound-<event>`: a string, the path of an MP3 hosted by the app. No token, no sound, silently.
9
+ * - `--cd-sound-<event>-ogg`: a string, an Ogg/Opus alternative the browser may prefer.
10
+ * - `--cd-sound-volume`: 0 to 1, how loud every sound is. `--cd-sound-<event>-volume`: 0 to 1, scales that for one event.
11
+ *
12
+ * Safe to import on the server: the document is touched only inside `readSoundToken`.
13
+ */
14
+ /** Every event, in the order the settings control lists them. */
15
+ export const soundEvents = ["needs-you", "finished", "failed", "reply", "silent"];
16
+ /** The events a sound is offered for once the person turns sound on; `reply` and `silent` stay off until they pick them. */
17
+ export const defaultSoundEvents = ["needs-you", "finished", "failed"];
18
+ /** The longest a sound may be, in seconds. `theme check` measures what it can read. */
19
+ export const maxSoundSeconds = 1.5;
20
+ /** The least time between two plays of the same event, in milliseconds. A burst plays once. */
21
+ export const soundCoalesceMs = 3000;
22
+ /** A CSS custom property's value as the text it names: quotes removed, or the inside of `url(…)`. Empty when unset. */
23
+ export function soundString(raw) {
24
+ const v = raw.trim();
25
+ const url = /^url\(\s*(['"]?)(.*?)\1\s*\)$/.exec(v);
26
+ const text = url ? (url[2] ?? "") : v;
27
+ return text.replace(/^(['"])(.*)\1$/, "$2").trim();
28
+ }
29
+ const unit = (raw) => {
30
+ const n = Number.parseFloat(raw);
31
+ return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : undefined;
32
+ };
33
+ const styleOf = (element) => typeof document === "undefined" || typeof getComputedStyle === "undefined" ? undefined : getComputedStyle(element ?? document.documentElement);
34
+ /** How loud `event` is, 0 to 1: the global `--cd-sound-volume` times the event's own, each 1 when unset. 1 on the server. */
35
+ export function readSoundVolume(event, element) {
36
+ const style = styleOf(element);
37
+ if (!style)
38
+ return 1;
39
+ return (unit(style.getPropertyValue("--cd-sound-volume")) ?? 1) * (unit(style.getPropertyValue(`--cd-sound-${event}-volume`)) ?? 1);
40
+ }
41
+ /**
42
+ * The brand's sound for `event`, read from the computed style of `element` (the document's root when left out), or
43
+ * `undefined` when the brand names none, or on the server.
44
+ */
45
+ export function readSoundToken(event, element) {
46
+ const style = styleOf(element);
47
+ const src = style && soundString(style.getPropertyValue(`--cd-sound-${event}`));
48
+ if (!style || !src)
49
+ return undefined;
50
+ const ogg = soundString(style.getPropertyValue(`--cd-sound-${event}-ogg`));
51
+ return { src, ...(ogg && { ogg }), volume: readSoundVolume(event, element) };
52
+ }
53
+ /**
54
+ * Whether a play at `now` is allowed, given when each event last played and when anything last played: the same event
55
+ * waits `soundCoalesceMs` after it played, and any sound in the same instant (a burst) is the one already playing.
56
+ * Pure; the caller keeps the clock.
57
+ */
58
+ export function mayPlay(event, last, now, burstMs = 400) {
59
+ const before = last[event];
60
+ if (before !== undefined && now - before < soundCoalesceMs)
61
+ return false;
62
+ return !soundEvents.some((e) => (last[e] ?? -Infinity) > now - burstMs);
63
+ }
package/dist/styles.css CHANGED
@@ -9,7 +9,6 @@
9
9
  * Tailwind skips node_modules when it scans your app.
10
10
  */
11
11
  @import "@fontsource-variable/inter/opsz.css";
12
- @import "tw-animate-css";
13
12
 
14
13
  @source "./**/*.js";
15
14
 
@@ -1104,3 +1103,22 @@
1104
1103
  animation: none !important;
1105
1104
  }
1106
1105
  }
1106
+
1107
+ /*
1108
+ * Sound (2.4). Candor sets no defaults here: no sound plays unless a brand file names one, and no sound plays at all
1109
+ * until the person turns sound on in your app. These are the names a brand file may set, in `:root`. Each value is a
1110
+ * string, the path of a file your app hosts (MP3, at most 1.5 s); nothing is fetched until a sound plays.
1111
+ *
1112
+ * --cd-sound-needs-you a new approval, question or disambiguation waits on the person
1113
+ * --cd-sound-finished a run the person started finished
1114
+ * --cd-sound-failed a run failed, or a guardrail stopped it
1115
+ * --cd-sound-reply a person (not an agent) replied in a thread the person is in
1116
+ * --cd-sound-silent a run the person started has not been heard from past its `staleAfter`
1117
+ *
1118
+ * --cd-sound-<event>-ogg optional Ogg/Opus alternative for the same event, as a string
1119
+ * --cd-sound-volume 0 to 1, how loud every sound is (1 when left out)
1120
+ * --cd-sound-<event>-volume 0 to 1, scales the global volume for one event (1 when left out)
1121
+ *
1122
+ * `useAttention` (Pro) reads them when a signal lands; `sound-setting` (Pro) plays them as a preview. Free items
1123
+ * never play a sound. The starter set (`sounds`, Pro) installs /sounds/<event>.mp3 for all five.
1124
+ */
@@ -7,7 +7,9 @@ import * as React from "react";
7
7
  * For a short notice that goes away by itself use a toast; an Alert doesn't dismiss itself.
8
8
  *
9
9
  * @motion arrive:enter An Alert that appears after the page has drawn rises in over a quarter of a second. One that is
10
- * there when the page first draws, or when it is rendered on the server, is simply there.
10
+ * there when the page first draws, or when it is rendered on the server, is simply there. "After the page has drawn" is
11
+ * counted from when the page loaded, not from the route: after a client-side navigation, an Alert on the new route
12
+ * rises in.
11
13
  */
12
14
  declare function Alert({ className, variant, role, style, ...props }: React.ComponentProps<"div"> & {
13
15
  /** The kind of message. `destructive` is for a failure or something that can't be undone; `warning` for care before acting; `attention` for something waiting on the person; `success` for a confirmed result. Defaults to a plain card. */
package/dist/ui/alert.js CHANGED
@@ -5,8 +5,17 @@ import { cva } from "class-variance-authority";
5
5
  import { cn } from "../lib/utils.js";
6
6
  import { motion } from "../lib/motion.js";
7
7
  const enterMotion = motion("alert", "enter", "arrive");
8
- // Set once a first Alert has been drawn and the browser has painted; an Alert that mounts after that has arrived.
8
+ // Set one frame after this file loads in the browser, which is when the page's first draw is done; an Alert that mounts
9
+ // after that has arrived. It is per page load, not per route: after a client-side route change, an Alert on the new route
10
+ // rises in, because it arrived after the page drew. An Alert in the first render, or on the server, is simply there.
9
11
  let pageDrawn = false;
12
+ if (typeof window !== "undefined") {
13
+ const frame = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame.bind(window) : (cb) => window.setTimeout(cb, 0);
14
+ frame(() => {
15
+ pageDrawn = true;
16
+ });
17
+ }
18
+ const subscribeNever = () => () => { };
10
19
  const alertVariants = cva("relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", {
11
20
  variants: {
12
21
  variant: {
@@ -27,17 +36,16 @@ const alertVariants = cva("relative grid w-full grid-cols-[0_1fr] items-start ga
27
36
  * For a short notice that goes away by itself use a toast; an Alert doesn't dismiss itself.
28
37
  *
29
38
  * @motion arrive:enter An Alert that appears after the page has drawn rises in over a quarter of a second. One that is
30
- * there when the page first draws, or when it is rendered on the server, is simply there.
39
+ * there when the page first draws, or when it is rendered on the server, is simply there. "After the page has drawn" is
40
+ * counted from when the page loaded, not from the route: after a client-side navigation, an Alert on the new route
41
+ * rises in.
31
42
  */
32
43
  function Alert({ className, variant, role, style, ...props }) {
33
- // Whether this Alert mounted after the page's first draw. Decided once, so a re-render doesn't replay it.
34
- const [arrived] = React.useState(() => pageDrawn);
35
- React.useEffect(() => {
36
- const raf = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame : (cb) => window.setTimeout(cb, 0);
37
- raf(() => {
38
- pageDrawn = true;
39
- });
40
- }, []);
44
+ // Whether this Alert mounted after the page's first draw. The server and the hydrating render answer no, so what
45
+ // the server sent is what hydrates; an Alert the browser creates later answers from the page. Decided once, so a
46
+ // re-render doesn't replay it.
47
+ const drawn = React.useSyncExternalStore(subscribeNever, () => pageDrawn, () => false);
48
+ const [arrived] = React.useState(drawn);
41
49
  return (_jsx("div", { "data-slot": "alert", "data-variant": variant ?? "default", role: role ?? (variant === "destructive" ? "alert" : "status"), style: { ...enterMotion, ...style }, className: cn(alertVariants({ variant }), arrived && "cd-arrive", className), ...props }));
42
50
  }
43
51
  /** The alert's headline: what happened or what to know, in a few words. */
@@ -4,7 +4,7 @@ import { type RenderProp } from "../lib/render-prop.js";
4
4
  /**
5
5
  * A bar for work whose progress is measured: an upload, an import, an index. It moves only between values the service
6
6
  * reported, and it starts at the first value it is given rather than filling from empty. With no `value` it is
7
- * indeterminate: a moving sweep and the `label` as text, and never a made-up percentage.
7
+ * indeterminate: a pulsing bar and the `label` as text, and never a made-up percentage.
8
8
  *
9
9
  * @motion fill:value The filled part moves to each new measured value in the slow duration, and not on first draw.
10
10
  * @motion live:indeterminate While the total is unknown, the bar pulses (the live family's pulse) and the label shimmers
@@ -10,7 +10,7 @@ const indeterminateMotion = motion("progress", "indeterminate", "live");
10
10
  /**
11
11
  * A bar for work whose progress is measured: an upload, an import, an index. It moves only between values the service
12
12
  * reported, and it starts at the first value it is given rather than filling from empty. With no `value` it is
13
- * indeterminate: a moving sweep and the `label` as text, and never a made-up percentage.
13
+ * indeterminate: a pulsing bar and the `label` as text, and never a made-up percentage.
14
14
  *
15
15
  * @motion fill:value The filled part moves to each new measured value in the slow duration, and not on first draw.
16
16
  * @motion live:indeterminate While the total is unknown, the bar pulses (the live family's pulse) and the label shimmers
@@ -7,7 +7,7 @@ import { type RenderProp } from "../lib/render-prop.js";
7
7
  * stays focusable, says why, and doesn't flip. Use a switch for a setting that takes effect at once; a choice the
8
8
  * person confirms belongs in a `Checkbox` or `RadioGroup` with a button.
9
9
  *
10
- * @motion move:thumb The thumb slides to the other end when the state changes, in the quick duration. It follows the
10
+ * @motion move:thumb The thumb slides to the other end when the state changes, over the move family's 200 ms. It follows the
11
11
  * reading direction, so it slides the other way in right-to-left text.
12
12
  */
13
13
  declare function Switch(allProps: React.ComponentProps<typeof SwitchPrimitive.Root> & RenderProp): React.JSX.Element;
package/dist/ui/switch.js CHANGED
@@ -12,7 +12,7 @@ const thumbMotion = motion("switch", "thumb", "move");
12
12
  * stays focusable, says why, and doesn't flip. Use a switch for a setting that takes effect at once; a choice the
13
13
  * person confirms belongs in a `Checkbox` or `RadioGroup` with a button.
14
14
  *
15
- * @motion move:thumb The thumb slides to the other end when the state changes, in the quick duration. It follows the
15
+ * @motion move:thumb The thumb slides to the other end when the state changes, over the move family's 200 ms. It follows the
16
16
  * reading direction, so it slides the other way in right-to-left text.
17
17
  */
18
18
  function Switch(allProps) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candor-ui/react",
3
- "version": "2.3.0",
3
+ "version": "2.4.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",
@@ -50,8 +50,7 @@
50
50
  "clsx": "^2.1.1",
51
51
  "lucide-react": "^1.67.0",
52
52
  "radix-ui": "^1.8.1",
53
- "tailwind-merge": "^3.7.0",
54
- "tw-animate-css": "^1.4.0"
53
+ "tailwind-merge": "^3.7.0"
55
54
  },
56
55
  "peerDependencies": {
57
56
  "@ag-ui/client": "^1.0.3",