@candor-ui/react 1.8.0 → 1.9.1

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.
@@ -7,7 +7,8 @@ export declare const copyFieldStrings: {
7
7
  /**
8
8
  * A value a person copies somewhere else (a callback URL, a scope list). The
9
9
  * whole field is the copy target; it says "Copied" only once the clipboard
10
- * took the value.
10
+ * took the value, and its tick (or the cross of a refused write) appears then,
11
+ * never while the write is still pending.
11
12
  */
12
13
  declare function CopyField({ label, value, className, }: {
13
14
  /** What the value is, shown above it. */
@@ -13,11 +13,12 @@ export const copyFieldStrings = {
13
13
  /**
14
14
  * A value a person copies somewhere else (a callback URL, a scope list). The
15
15
  * whole field is the copy target; it says "Copied" only once the clipboard
16
- * took the value.
16
+ * took the value, and its tick (or the cross of a refused write) appears then,
17
+ * never while the write is still pending.
17
18
  */
18
19
  function CopyField({ label, value, className, }) {
19
20
  const { state, copy } = useCopy();
20
21
  const t = useStrings("copyField", copyFieldStrings);
21
- return (_jsxs("div", { "data-slot": "copy-field", className: cn("flex flex-col gap-1", className), children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: label }), _jsxs("button", { type: "button", onClick: () => copy(value), className: "group/copy flex w-full items-center gap-2 rounded-lg border bg-sunken px-3 py-2 text-start outline-none transition-colors duration-150 hover:border-border-strong focus-visible:ring-[3px] focus-visible:ring-ring/80", children: [_jsx("code", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: value }), _jsx("span", { "aria-live": "polite", className: "inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground group-hover/copy:text-foreground", children: state === "copied" ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "size-3 text-success-text" }), " ", t("copied")] })) : state === "failed" ? (_jsxs(_Fragment, { children: [_jsx(X, { className: "size-3 text-destructive-text" }), " ", t("failed")] })) : (_jsxs(_Fragment, { children: [_jsx(Copy, { className: "size-3" }), " ", t("copy")] })) })] })] }));
22
+ return (_jsxs("div", { "data-slot": "copy-field", className: cn("flex flex-col gap-1", className), children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: label }), _jsxs("button", { type: "button", onClick: () => copy(value), className: "group/copy flex w-full items-center gap-2 rounded-lg border bg-sunken px-3 py-2 text-start outline-none transition-colors duration-150 hover:border-border-strong focus-visible:ring-[3px] focus-visible:ring-ring/80", children: [_jsx("code", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: value }), _jsx("span", { "aria-live": "polite", "data-copy": state, className: "inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground group-hover/copy:text-foreground", children: state === "copied" ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "size-3 text-success-text animate-pop-in" }), " ", t("copied")] })) : state === "failed" ? (_jsxs(_Fragment, { children: [_jsx(X, { className: "size-3 text-destructive-text animate-pop-in" }), " ", t("failed")] })) : (_jsxs(_Fragment, { children: [_jsx(Copy, { className: "size-3" }), " ", t("copy")] })) })] })] }));
22
23
  }
23
24
  export { CopyField };
@@ -13,7 +13,10 @@ type Lockable = React.ReactElement<{
13
13
  * marked `aria-disabled`, its activation is blocked (click, Enter, Space), and
14
14
  * it is described by the reason — so keyboard and screen-reader users reach
15
15
  * the explanation on the control itself. The reason shows as a tooltip, or
16
- * printed under the control with `inline`.
16
+ * printed under the control with `inline`. Pressing the control reveals the
17
+ * reason (the tooltip is held open, the printed reason is emphasised) and,
18
+ * under data-motion="expressive" only, shakes it briefly; with reduced motion
19
+ * or the default subtle preset, only the reason is shown.
17
20
  */
18
21
  declare function Locked({ reason, inline, children, className, }: {
19
22
  /** Why the control can't be used. Without one, the control renders as normal. */
@@ -11,15 +11,37 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
11
11
  * marked `aria-disabled`, its activation is blocked (click, Enter, Space), and
12
12
  * it is described by the reason — so keyboard and screen-reader users reach
13
13
  * the explanation on the control itself. The reason shows as a tooltip, or
14
- * printed under the control with `inline`.
14
+ * printed under the control with `inline`. Pressing the control reveals the
15
+ * reason (the tooltip is held open, the printed reason is emphasised) and,
16
+ * under data-motion="expressive" only, shakes it briefly; with reduced motion
17
+ * or the default subtle preset, only the reason is shown.
15
18
  */
16
19
  function Locked({ reason, inline = false, children, className, }) {
17
20
  const id = React.useId();
21
+ // The tooltip as hover and focus drive it, and a press, which holds the reason open and shakes (when expressive).
22
+ // `press` counts presses so a second one restarts both timers.
23
+ const [shown, setShown] = React.useState(false);
24
+ const [press, setPress] = React.useState(0);
25
+ const [shaking, setShaking] = React.useState(false);
26
+ const [held, setHeld] = React.useState(false);
27
+ React.useEffect(() => {
28
+ if (press === 0)
29
+ return;
30
+ const shake = window.setTimeout(() => setShaking(false), 400);
31
+ const hold = window.setTimeout(() => setHeld(false), 1800);
32
+ return () => {
33
+ window.clearTimeout(shake);
34
+ window.clearTimeout(hold);
35
+ };
36
+ }, [press]);
18
37
  if (!reason)
19
38
  return children;
20
39
  const block = (e) => {
21
40
  e.preventDefault();
22
41
  e.stopPropagation();
42
+ setPress((n) => n + 1);
43
+ setShaking(true);
44
+ setHeld(true);
23
45
  };
24
46
  const control = React.cloneElement(children, {
25
47
  "aria-disabled": true,
@@ -31,6 +53,6 @@ function Locked({ reason, inline = false, children, className, }) {
31
53
  block(e);
32
54
  },
33
55
  });
34
- return (_jsxs("span", { "data-slot": "locked", className: cn("relative inline-flex flex-col items-start gap-1.5", className), children: [inline ? (control) : (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: control }), _jsx(TooltipContent, { children: reason })] })), _jsxs("span", { id: id, className: cn("items-center gap-1 text-xs text-muted-foreground", inline ? "inline-flex" : "sr-only"), children: [_jsx(LockKeyhole, { "aria-hidden": true, className: "size-3" }), reason] })] }));
56
+ return (_jsxs("span", { "data-slot": "locked", "data-pressed": held ? "" : undefined, className: cn("relative inline-flex flex-col items-start gap-1.5", shaking && "animate-shake", 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", className: cn("items-center gap-1 text-xs text-muted-foreground transition-colors duration-(--cd-dur-base)", inline ? "inline-flex" : "sr-only", held && "text-foreground"), children: [_jsx(LockKeyhole, { "aria-hidden": true, className: "size-3" }), reason] })] }));
35
57
  }
36
58
  export { Locked };
@@ -1,7 +1,10 @@
1
+ import * as React from "react";
1
2
  import { type RunStatus } from "../lib/status.js";
2
3
  /**
3
4
  * A small status mark. Running and waiting pulse, since they are the two
4
- * states a person may need to watch or act on. The label is always available
5
+ * states a person may need to watch or act on. When one of them reaches done
6
+ * or failed while on screen, the new dot pops into place (too small for a drawn
7
+ * mark); a dot that mounts finished is still. The label is always available
5
8
  * to assistive tech; pass `showLabel` to print it too.
6
9
  */
7
10
  declare function StatusDot({ status, showLabel, label, className, }: {
@@ -12,5 +15,5 @@ declare function StatusDot({ status, showLabel, label, className, }: {
12
15
  /** Words to use instead of the status name. */
13
16
  label?: string | undefined;
14
17
  className?: string | undefined;
15
- }): import("react").JSX.Element;
18
+ }): React.JSX.Element;
16
19
  export { StatusDot };
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
3
4
  import { cn } from "../lib/utils.js";
4
5
  import { useStatusLabel } from "../lib/status.js";
5
6
  const dotColor = {
@@ -13,15 +14,35 @@ const dotColor = {
13
14
  cancelled: "bg-faint-foreground/60",
14
15
  skipped: "bg-faint-foreground/40",
15
16
  };
17
+ const finished = (status) => status === "succeeded" || status === "failed";
18
+ const over = (status) => finished(status) || status === "cancelled" || status === "skipped";
19
+ /**
20
+ * How many times the status reached done or failed from an unfinished one while the dot was on screen; zero when it
21
+ * mounted finished, so nothing moves for a change the reader never saw.
22
+ */
23
+ function useSettled(status) {
24
+ const [settled, setSettled] = React.useState(0);
25
+ const previous = React.useRef(status);
26
+ React.useLayoutEffect(() => {
27
+ const was = previous.current;
28
+ previous.current = status;
29
+ if (was !== status && !over(was) && finished(status))
30
+ setSettled((n) => n + 1);
31
+ }, [status]);
32
+ return settled;
33
+ }
16
34
  /**
17
35
  * A small status mark. Running and waiting pulse, since they are the two
18
- * states a person may need to watch or act on. The label is always available
36
+ * states a person may need to watch or act on. When one of them reaches done
37
+ * or failed while on screen, the new dot pops into place (too small for a drawn
38
+ * mark); a dot that mounts finished is still. The label is always available
19
39
  * to assistive tech; pass `showLabel` to print it too.
20
40
  */
21
41
  function StatusDot({ status, showLabel = false, label, className, }) {
22
42
  const statusLabel = useStatusLabel();
23
43
  const text = label ?? statusLabel(status);
24
44
  const live = status === "running" || status === "waiting";
25
- return (_jsxs("span", { "data-slot": "status-dot", "data-status": status, className: cn("inline-flex items-center gap-1.5 text-xs text-muted-foreground", className), children: [_jsx("span", { "aria-hidden": true, className: cn("size-2 shrink-0 rounded-full", dotColor[status], live && "animate-pulse-dot") }), showLabel ? _jsx("span", { children: text }) : _jsx("span", { className: "sr-only", children: text })] }));
45
+ const settled = useSettled(status);
46
+ return (_jsxs("span", { "data-slot": "status-dot", "data-status": status, "data-settled": settled > 0 ? "" : undefined, className: cn("inline-flex items-center gap-1.5 text-xs text-muted-foreground", className), children: [_jsx("span", { "aria-hidden": true, className: cn("size-2 shrink-0 rounded-full", dotColor[status], live && "animate-pulse-dot", settled > 0 && "animate-pop-in") }, settled), showLabel ? _jsx("span", { children: text }) : _jsx("span", { className: "sr-only", children: text })] }));
26
47
  }
27
48
  export { StatusDot };
@@ -7,13 +7,18 @@ export type Step = {
7
7
  detail?: React.ReactNode | undefined;
8
8
  meta?: React.ReactNode | undefined;
9
9
  };
10
+ /**
11
+ * A step's status as a mark. When the status reaches done or failed while the mark is on screen, the spinner
12
+ * becomes a tick or cross drawn along its path; a mark that mounts already finished is drawn still.
13
+ */
10
14
  declare function StepMark({ status, }: {
11
15
  /** The step's status, which picks its mark. */
12
16
  status: RunStatus;
13
17
  }): React.JSX.Element;
14
18
  /**
15
19
  * An ordered list of steps joined by a rail: an agent's plan, its reasoning,
16
- * a run's stages. The rail fills as steps finish.
20
+ * a run's stages. The rail fills to each step as it finishes, never ahead of
21
+ * it, and a step that finishes while on screen draws its mark.
17
22
  */
18
23
  declare function StepList({ steps, className, }: {
19
24
  /** The steps in order, each with a status, and optional detail and meta. */
@@ -1,16 +1,48 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
- import { Check, CircleSlash, Hand, LoaderCircle, X } from "lucide-react";
4
+ import { CircleSlash, Hand, LoaderCircle } from "lucide-react";
5
5
  import { cn } from "../lib/utils.js";
6
6
  import { useStatusLabel } from "../lib/status.js";
7
+ const finished = (status) => status === "succeeded" || status === "failed";
8
+ const over = (status) => finished(status) || status === "cancelled" || status === "skipped";
9
+ /**
10
+ * How many times `status` has reached succeeded or failed from an unfinished status while this component was on
11
+ * screen. Zero for something that mounted already finished, so its mark is drawn still: motion reports a change
12
+ * that was seen, never one that happened before the reader arrived.
13
+ */
14
+ function useSettled(status) {
15
+ const [settled, setSettled] = React.useState(0);
16
+ const previous = React.useRef(status);
17
+ React.useLayoutEffect(() => {
18
+ const was = previous.current;
19
+ previous.current = status;
20
+ if (was !== status && !over(was) && finished(status))
21
+ setSettled((n) => n + 1);
22
+ }, [status]);
23
+ return settled;
24
+ }
25
+ /** A tick, drawn along its path when `draw` is above zero (and again each time it changes). */
26
+ function Tick({ draw }) {
27
+ return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 3, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, className: "size-3", children: _jsx("path", { d: "M20 6 9 17l-5-5", pathLength: 1, className: cn(draw > 0 && "[stroke-dasharray:1] animate-tick-draw") }) }, draw));
28
+ }
29
+ /** A cross, its two strokes drawn one after the other when `draw` is above zero. */
30
+ function Cross({ draw }) {
31
+ const drawn = draw > 0 && "[stroke-dasharray:1] animate-tick-draw";
32
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 3, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, className: "size-3", children: [_jsx("path", { d: "M18 6 6 18", pathLength: 1, className: cn(drawn) }), _jsx("path", { d: "m6 6 12 12", pathLength: 1, className: cn(drawn, drawn && "[animation-delay:var(--cd-dur-quick)]") })] }, draw));
33
+ }
34
+ /**
35
+ * A step's status as a mark. When the status reaches done or failed while the mark is on screen, the spinner
36
+ * becomes a tick or cross drawn along its path; a mark that mounts already finished is drawn still.
37
+ */
7
38
  function StepMark({ status, }) {
8
- const base = "relative z-10 flex size-5 shrink-0 items-center justify-center rounded-full";
39
+ const settled = useSettled(status);
40
+ const base = "relative z-10 flex size-5 shrink-0 items-center justify-center rounded-full transition-colors duration-(--cd-dur-base) ease-standard";
9
41
  switch (status) {
10
42
  case "succeeded":
11
- return (_jsx("span", { className: cn(base, "bg-success text-on-success"), children: _jsx(Check, { className: "size-3", strokeWidth: 3 }) }));
43
+ return (_jsx("span", { className: cn(base, "bg-success text-on-success"), "data-settled": settled > 0 ? "" : undefined, children: _jsx(Tick, { draw: settled }) }));
12
44
  case "failed":
13
- return (_jsx("span", { className: cn(base, "bg-destructive text-on-destructive"), children: _jsx(X, { className: "size-3", strokeWidth: 3 }) }));
45
+ return (_jsx("span", { className: cn(base, "bg-destructive text-on-destructive"), "data-settled": settled > 0 ? "" : undefined, children: _jsx(Cross, { draw: settled }) }));
14
46
  case "running":
15
47
  return (_jsx("span", { className: cn(base, "bg-accent-weak text-accent-text"), children: _jsx(LoaderCircle, { className: "size-3.5 animate-spin", strokeWidth: 2.5 }) }));
16
48
  case "waiting":
@@ -24,16 +56,18 @@ function StepMark({ status, }) {
24
56
  return (_jsx("span", { className: cn(base, "bg-background"), children: _jsx("span", { className: "size-2 rounded-full border-[1.5px] border-faint-foreground/70" }) }));
25
57
  }
26
58
  }
59
+ function StepRow({ step, index, last, }) {
60
+ const statusLabel = useStatusLabel();
61
+ const done = step.status === "succeeded";
62
+ const settled = useSettled(step.status);
63
+ return (_jsxs("li", { className: "relative flex gap-3 pb-4 last:pb-0 animate-rise-in", style: { animationDelay: `${index * 40}ms` }, children: [!last && (_jsx("span", { "aria-hidden": true, className: "absolute top-5 bottom-0 start-[9.5px] w-px bg-input", children: done && (_jsx("span", { "data-slot": "step-rail-fill", className: cn("absolute inset-0 origin-top bg-success/50", settled > 0 && "animate-fill [--cd-fill-from:1_0]") }, settled)) })), _jsx(StepMark, { status: step.status }), _jsxs("div", { className: "min-w-0 flex-1 pt-px", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("p", { className: cn("text-sm font-medium", step.status === "idle" && "text-muted-foreground", (step.status === "skipped" || step.status === "cancelled") && "text-faint-foreground line-through"), children: [step.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel(step.status)] })] }), step.meta && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: step.meta })] }), step.detail && _jsx("div", { className: "mt-1 text-[0.8125rem] text-muted-foreground", children: step.detail })] })] }));
64
+ }
27
65
  /**
28
66
  * An ordered list of steps joined by a rail: an agent's plan, its reasoning,
29
- * a run's stages. The rail fills as steps finish.
67
+ * a run's stages. The rail fills to each step as it finishes, never ahead of
68
+ * it, and a step that finishes while on screen draws its mark.
30
69
  */
31
70
  function StepList({ steps, className, }) {
32
- const statusLabel = useStatusLabel();
33
- return (_jsx("ol", { "data-slot": "step-list", className: cn("flex flex-col", className), children: steps.map((step, i) => {
34
- const last = i === steps.length - 1;
35
- const done = step.status === "succeeded";
36
- return (_jsxs("li", { className: "relative flex gap-3 pb-4 last:pb-0 animate-rise-in", style: { animationDelay: `${i * 40}ms` }, children: [!last && (_jsx("span", { "aria-hidden": true, className: cn("absolute top-5 bottom-0 start-[9.5px] w-px transition-colors duration-500", done ? "bg-success/50" : "bg-input") })), _jsx(StepMark, { status: step.status }), _jsxs("div", { className: "min-w-0 flex-1 pt-px", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("p", { className: cn("text-sm font-medium", step.status === "idle" && "text-muted-foreground", (step.status === "skipped" || step.status === "cancelled") && "text-faint-foreground line-through"), children: [step.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel(step.status)] })] }), step.meta && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: step.meta })] }), step.detail && _jsx("div", { className: "mt-1 text-[0.8125rem] text-muted-foreground", children: step.detail })] })] }, step.id));
37
- }) }));
71
+ return (_jsx("ol", { "data-slot": "step-list", className: cn("flex flex-col", className), children: steps.map((step, i) => (_jsx(StepRow, { step: step, index: i, last: i === steps.length - 1 }, step.id))) }));
38
72
  }
39
73
  export { StepList, StepMark };
@@ -62,11 +62,11 @@ declare function Composer({ value, onChange, onSubmit, onStop, state, scope, onS
62
62
  mentions?: MentionOption[] | undefined;
63
63
  /** What / can run, only at the start of a message. */
64
64
  commands?: SlashCommand[] | undefined;
65
- /** Entities already attached, shown as chips. */
65
+ /** Entities already attached, shown as chips. A chip added after the composer drew pops in; a removed one pops out. */
66
66
  references?: EntityRef[] | undefined;
67
67
  /** Called when a chip is added or removed. */
68
68
  onReferencesChange?: ((references: EntityRef[]) => void) | undefined;
69
- /** Files attached, with upload progress or an error. */
69
+ /** Files attached, with upload progress or an error, as chips that pop in when added and out when removed. */
70
70
  attachments?: Attachment[] | undefined;
71
71
  /** Shows an attach button; called with the chosen files. */
72
72
  onAttach?: ((files: File[]) => void) | undefined;
@@ -40,6 +40,58 @@ function findTrigger(value, caret) {
40
40
  const groupOf = (item, commandsLabel = "Commands") => (item.type === "command" ? commandsLabel : item.option.group);
41
41
  /** Whether a drag carries files (not text or a link being dragged around the page). */
42
42
  const hasFiles = (e) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
43
+ const referenceKey = (r) => `${r.kind}:${r.name}`;
44
+ const attachmentKey = (a) => a.id;
45
+ /**
46
+ * Which chips were added after the composer first drew (they pop in) and which were just removed (they pop out as
47
+ * inert ghosts, then go). Chips present at first render are drawn still: nothing moves for a state the reader
48
+ * already had. The ghosts are cleared by their animation's end, or after 400ms when nothing animates.
49
+ */
50
+ function useChipMotion(items, keyOf) {
51
+ const [entered, setEntered] = React.useState(() => new Set());
52
+ const [leaving, setLeaving] = React.useState([]);
53
+ const previous = React.useRef(null);
54
+ React.useLayoutEffect(() => {
55
+ const before = previous.current;
56
+ previous.current = items;
57
+ if (!before)
58
+ return;
59
+ const now = new Set(items.map(keyOf));
60
+ const was = new Set(before.map(keyOf));
61
+ const added = items.filter((item) => !was.has(keyOf(item))).map(keyOf);
62
+ const gone = before.filter((item) => !now.has(keyOf(item)));
63
+ if (added.length || gone.length) {
64
+ setEntered((set) => {
65
+ const next = new Set(set);
66
+ for (const item of gone)
67
+ next.delete(keyOf(item));
68
+ for (const key of added)
69
+ next.add(key);
70
+ return next;
71
+ });
72
+ }
73
+ // A chip added again while its ghost is still leaving takes the ghost's place: the ghost goes at once.
74
+ if (gone.length || added.length) {
75
+ setLeaving((list) => {
76
+ const kept = list.filter((item) => !now.has(keyOf(item)));
77
+ return kept.length === list.length && gone.length === 0 ? list : [...kept, ...gone];
78
+ });
79
+ }
80
+ }, [items, keyOf]);
81
+ React.useEffect(() => {
82
+ if (leaving.length === 0)
83
+ return;
84
+ const timer = window.setTimeout(() => setLeaving([]), 400);
85
+ return () => window.clearTimeout(timer);
86
+ }, [leaving]);
87
+ const entering = (item) => entered.has(keyOf(item));
88
+ const left = (item) => setLeaving((list) => list.filter((x) => x !== item));
89
+ return { entering, leaving, left };
90
+ }
91
+ /** A removed chip, kept for the length of its pop-out and hidden from assistive tech. */
92
+ function LeavingChip({ onDone, children }) {
93
+ return (_jsx("span", { "aria-hidden": true, "data-leaving": true, className: "pointer-events-none inline-flex max-w-full animate-pop-out", onAnimationEnd: onDone, children: children }));
94
+ }
43
95
  /**
44
96
  * Where a person asks. Mentions (@) attach things in the user's system as chips the agent
45
97
  * receives as references, not as text it has to parse; slash commands (/) start
@@ -66,6 +118,8 @@ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, o
66
118
  const fileInput = React.useRef(null);
67
119
  const [trigger, setTrigger] = React.useState(null);
68
120
  const [active, setActive] = React.useState(0);
121
+ const referenceMotion = useChipMotion(references, referenceKey);
122
+ const attachmentMotion = useChipMotion(attachments, attachmentKey);
69
123
  const held = typeof state === "object" && state.kind === "held" ? state : null;
70
124
  const locked = typeof state === "object" && state.kind !== "held" ? state : null;
71
125
  const canAttach = Boolean(onAttach) && !locked;
@@ -198,7 +252,7 @@ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, o
198
252
  e.preventDefault();
199
253
  choose(item);
200
254
  }, 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));
201
- }) })), _jsxs("div", { className: cn("rounded-2xl border bg-card shadow-raised transition-[border-color,box-shadow] duration-150 focus-within:border-border-strong focus-within:shadow-float", locked && "bg-muted shadow-none"), children: [(references.length > 0 || attachments.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)) }, `${r.kind}:${r.name}`))), attachments.map((a) => (_jsx(AttachmentChip, { attachment: a, onRemove: onRemoveAttachment }, 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) => {
255
+ }) })), _jsxs("div", { className: cn("rounded-2xl border bg-card shadow-raised transition-[border-color,box-shadow] duration-150 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)), className: cn(referenceMotion.entering(r) && "animate-pop-in") }, 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, className: cn(attachmentMotion.entering(a) && "animate-pop-in") }, 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) => {
202
256
  onChange(e.target.value);
203
257
  requestAnimationFrame(syncTrigger);
204
258
  }, onKeyDown: onKeyDown, onKeyUp: (e) => {
@@ -1,3 +1,4 @@
1
+ import * as React from "react";
1
2
  import type { ContextSegment } from "../lib/know.js";
2
3
  /** ContextMeter's fixed words; override them under `contextMeter` in LocaleProvider's `strings`. */
3
4
  export declare const contextMeterStrings: {
@@ -11,7 +12,9 @@ export declare const contextMeterStrings: {
11
12
  /**
12
13
  * What fills the model's context budget, as a ring in the composer that
13
14
  * opens to a breakdown: system, instructions, Know, the thread, tools, files.
14
- * Counts are the service's own token counts; the ring warns past 80%.
15
+ * Counts are the service's own token counts; the ring warns past 80%. When a
16
+ * new count arrives the ring sweeps to it and changes colour at the warning
17
+ * line; it draws its first value in place, with no sweep from zero.
15
18
  */
16
19
  declare function ContextMeter({ segments, limit, className, }: {
17
20
  /** What fills the context window, by kind, in tokens. */
@@ -19,5 +22,5 @@ declare function ContextMeter({ segments, limit, className, }: {
19
22
  /** The model's context window, in tokens. */
20
23
  limit: number;
21
24
  className?: string | undefined;
22
- }): import("react").JSX.Element;
25
+ }): React.JSX.Element;
23
26
  export { ContextMeter };
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
3
4
  import { cn } from "../lib/utils.js";
4
5
  import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
5
6
  import { useFormat, useStrings } from "../blocks/locale.js";
@@ -29,7 +30,9 @@ function k(n, format, thousands) {
29
30
  /**
30
31
  * What fills the model's context budget, as a ring in the composer that
31
32
  * opens to a breakdown: system, instructions, Know, the thread, tools, files.
32
- * Counts are the service's own token counts; the ring warns past 80%.
33
+ * Counts are the service's own token counts; the ring warns past 80%. When a
34
+ * new count arrives the ring sweeps to it and changes colour at the warning
35
+ * line; it draws its first value in place, with no sweep from zero.
33
36
  */
34
37
  function ContextMeter({ segments, limit, className, }) {
35
38
  const t = useStrings("contextMeter", contextMeterStrings);
@@ -40,7 +43,10 @@ function ContextMeter({ segments, limit, className, }) {
40
43
  const warn = pct > 0.8;
41
44
  const r = 7;
42
45
  const c = 2 * Math.PI * r;
43
- return (_jsxs(Popover, { children: [_jsxs(PopoverTrigger, { "aria-label": t("label", { used: tokens(used), limit: tokens(limit) }), 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", { cx: "9", cy: "9", r: r, fill: "none", stroke: warn ? "var(--warning)" : "var(--primary)", strokeWidth: "2.5", strokeDasharray: `${pct * c} ${c}`, strokeLinecap: "round" })] }), 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]
46
+ // The sweep is a transition, so it runs only for a value that changed after the first one was drawn.
47
+ const [drawn, setDrawn] = React.useState(false);
48
+ React.useEffect(() => setDrawn(true), []);
49
+ 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", className: cn(drawn && "transition-[stroke-dasharray,stroke] duration-(--cd-dur-slow) ease-standard") })] }), 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]
44
50
  .sort((a, b) => b.tokens - a.tokens)
45
51
  .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") })] })] }));
46
52
  }
@@ -11,8 +11,10 @@ export declare const messageActionsStrings: {
11
11
  export type Rating = "up" | "down" | null;
12
12
  /**
13
13
  * Copy, retry and rate under an agent's reply. Each action appears only when
14
- * its handler is given. Copy reports what the clipboard did. A rating is the
15
- * caller's state, so it shows only what the service recorded.
14
+ * its handler is given. Copy reports what the clipboard did: the icon stays a
15
+ * copy icon until the write settles, then becomes a tick (or a cross when the
16
+ * clipboard refused). A rating is the caller's state, so it shows only what
17
+ * the service recorded.
16
18
  */
17
19
  declare function MessageActions({ text, onRetry, rating, onRate, meta, className, }: {
18
20
  /** What Copy copies; without it there's no Copy. */
@@ -20,12 +20,14 @@ function ActionButton({ label, onClick, pressed, children, }) {
20
20
  }
21
21
  /**
22
22
  * Copy, retry and rate under an agent's reply. Each action appears only when
23
- * its handler is given. Copy reports what the clipboard did. A rating is the
24
- * caller's state, so it shows only what the service recorded.
23
+ * its handler is given. Copy reports what the clipboard did: the icon stays a
24
+ * copy icon until the write settles, then becomes a tick (or a cross when the
25
+ * clipboard refused). A rating is the caller's state, so it shows only what
26
+ * the service recorded.
25
27
  */
26
28
  function MessageActions({ text, onRetry, rating, onRate, meta, className, }) {
27
29
  const { state, copy } = useCopy();
28
30
  const t = useStrings("messageActions", messageActionsStrings);
29
- return (_jsxs("div", { "data-slot": "message-actions", className: cn("flex items-center gap-0.5", className), children: [text !== undefined && (_jsx(ActionButton, { label: state === "copied" ? t("copied") : state === "failed" ? t("copyFailed") : t("copy"), onClick: () => copy(text), children: state === "copied" ? _jsx(Check, { className: "text-success-text" }) : state === "failed" ? _jsx(X, { className: "text-destructive-text" }) : _jsx(Copy, {}) })), onRetry && (_jsx(ActionButton, { label: t("retry"), onClick: onRetry, children: _jsx(RotateCw, {}) })), onRate && (_jsxs(_Fragment, { children: [_jsx(ActionButton, { label: t("good"), pressed: rating === "up", onClick: () => onRate(rating === "up" ? null : "up"), children: _jsx(ThumbsUp, { className: cn(rating === "up" && "fill-current") }) }), _jsx(ActionButton, { label: t("bad"), pressed: rating === "down", onClick: () => onRate(rating === "down" ? null : "down"), children: _jsx(ThumbsDown, { className: cn(rating === "down" && "fill-current") }) })] })), meta && _jsx("span", { className: "ms-1.5 truncate text-xs text-faint-foreground", children: meta })] }));
31
+ return (_jsxs("div", { "data-slot": "message-actions", className: cn("flex items-center gap-0.5", className), children: [text !== undefined && (_jsx(ActionButton, { label: state === "copied" ? t("copied") : state === "failed" ? t("copyFailed") : t("copy"), onClick: () => copy(text), children: state === "copied" ? (_jsx(Check, { "data-copy": "copied", className: "text-success-text animate-pop-in" })) : state === "failed" ? (_jsx(X, { "data-copy": "failed", className: "text-destructive-text animate-pop-in" })) : (_jsx(Copy, { "data-copy": state })) })), onRetry && (_jsx(ActionButton, { label: t("retry"), onClick: onRetry, children: _jsx(RotateCw, {}) })), onRate && (_jsxs(_Fragment, { children: [_jsx(ActionButton, { label: t("good"), pressed: rating === "up", onClick: () => onRate(rating === "up" ? null : "up"), children: _jsx(ThumbsUp, { className: cn(rating === "up" && "fill-current") }) }), _jsx(ActionButton, { label: t("bad"), pressed: rating === "down", onClick: () => onRate(rating === "down" ? null : "down"), children: _jsx(ThumbsDown, { className: cn(rating === "down" && "fill-current") }) })] })), meta && _jsx("span", { className: "ms-1.5 truncate text-xs text-faint-foreground", children: meta })] }));
30
32
  }
31
33
  export { MessageActions };
@@ -108,6 +108,11 @@ export type PermissionAnswer = {
108
108
  * input, nothing else. "Don't ask again" rules are offered as boxes nobody has ticked, and are kept only if the
109
109
  * person ticks them. When the agent is asking questions instead, it shows them as a short form. Words from the
110
110
  * request (its title, reason, input) are shown as text, never markup.
111
+ *
112
+ * It sits in the thread, with no backdrop and nothing that dismisses it: the only ways out are Allow and Deny, and
113
+ * nothing else is ever counted as a denial. While the decision waits, its shield pulses slowly; a press on inert
114
+ * space outside it (the gesture that would dismiss a dialog) nudges it instead, so the person sees it is still
115
+ * waiting. Both stop once the answer is being sent.
111
116
  */
112
117
  declare function PermissionPrompt({ request, mode, suggestions, editable, onAllow, onDeny, busy, className, }: {
113
118
  /** The waiting request. */
@@ -122,7 +127,7 @@ declare function PermissionPrompt({ request, mode, suggestions, editable, onAllo
122
127
  onAllow: (answer: PermissionAnswer) => void;
123
128
  /** Called when the person denies it (or won't answer the questions). */
124
129
  onDeny: () => void;
125
- /** The answer is being sent: both buttons wait. */
130
+ /** The answer is being sent: both buttons wait, and the waiting pulse stops. */
126
131
  busy?: boolean | undefined;
127
132
  className?: string | undefined;
128
133
  }): React.JSX.Element;
@@ -60,6 +60,8 @@ const modeKeys = {
60
60
  bypassPermissions: "modeBypassPermissions",
61
61
  };
62
62
  const json = (v) => (typeof v === "string" ? v : JSON.stringify(v, null, 2));
63
+ /** What a press lands on when the person is doing something else, rather than clicking around the prompt. */
64
+ const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="option"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="textbox"], [role="slider"], [tabindex]:not([tabindex="-1"])';
63
65
  /** Parses edited JSON into an object, or null when it isn't one. */
64
66
  function parseObject(text) {
65
67
  try {
@@ -76,6 +78,11 @@ function parseObject(text) {
76
78
  * input, nothing else. "Don't ask again" rules are offered as boxes nobody has ticked, and are kept only if the
77
79
  * person ticks them. When the agent is asking questions instead, it shows them as a short form. Words from the
78
80
  * request (its title, reason, input) are shown as text, never markup.
81
+ *
82
+ * It sits in the thread, with no backdrop and nothing that dismisses it: the only ways out are Allow and Deny, and
83
+ * nothing else is ever counted as a denial. While the decision waits, its shield pulses slowly; a press on inert
84
+ * space outside it (the gesture that would dismiss a dialog) nudges it instead, so the person sees it is still
85
+ * waiting. Both stop once the answer is being sent.
79
86
  */
80
87
  function PermissionPrompt({ request, mode, suggestions, editable = false, onAllow, onDeny, busy = false, className, }) {
81
88
  // Keyed by the request, so an edit, a ticked rule or an answer never carries over to the next request.
@@ -101,6 +108,32 @@ function PromptBody({ request, mode, suggestions, editable, onAllow, onDeny, bus
101
108
  if (request.defaultToNo)
102
109
  denyRef.current?.focus();
103
110
  }, [request.id, request.defaultToNo]);
111
+ // A press on inert space outside every prompt, while it waits, nudges it rather than dismissing anything.
112
+ const section = React.useRef(null);
113
+ const [nudged, setNudged] = React.useState(false);
114
+ React.useEffect(() => {
115
+ if (busy)
116
+ return;
117
+ const onPress = (e) => {
118
+ const target = e.target;
119
+ if (!target || section.current?.contains(target))
120
+ return;
121
+ // A press anywhere inside another prompt is that prompt's business, not an outside press for this one.
122
+ if (target.closest('[data-slot="permission-prompt"]'))
123
+ return;
124
+ if (target.closest(INTERACTIVE))
125
+ return;
126
+ setNudged(true);
127
+ };
128
+ document.addEventListener("pointerdown", onPress);
129
+ return () => document.removeEventListener("pointerdown", onPress);
130
+ }, [busy]);
131
+ React.useEffect(() => {
132
+ if (!nudged)
133
+ return;
134
+ const timer = window.setTimeout(() => setNudged(false), 1000);
135
+ return () => window.clearTimeout(timer);
136
+ }, [nudged]);
104
137
  const word = (s) => {
105
138
  const key = { allow: "ruleAllow", deny: "ruleDeny", ask: "ruleAsk", remove: "ruleRemove", mode: "setMode", directories: "directories", other: "other" }[s.action];
106
139
  const scope = s.scope && { session: "scopeSession", local: "scopeLocal", project: "scopeProject", user: "scopeUser" }[s.scope];
@@ -133,7 +166,7 @@ function PromptBody({ request, mode, suggestions, editable, onAllow, onDeny, bus
133
166
  return;
134
167
  onAllow({ ...(edited && changedKeys.length && { updatedInput: edited }), ...(ticked.length && { alwaysAllow: ticked }) });
135
168
  };
136
- return (_jsxs("section", { "data-slot": "permission-prompt", "aria-label": t("label", { title: questions ? t("questions") : title }), className: cn("overflow-hidden rounded-xl border border-warning/50 bg-card text-sm shadow-xs", className), children: [_jsxs("header", { className: "flex items-start gap-2.5 bg-warning-weak/50 px-3.5 py-2.5", children: [_jsx(ShieldQuestion, { "aria-hidden": true, className: "mt-0.5 size-4 shrink-0 text-warning-text" }), _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) => {
169
+ 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 }), className: cn("overflow-hidden rounded-xl border border-warning/50 bg-card text-sm shadow-xs [--cd-attention-color:var(--warning)]", nudged && "animate-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, className: cn("mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-warning-text", !busy && "animate-attention-wait"), 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) => {
137
170
  const a = answers[q.id];
138
171
  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));
139
172
  }) })) : (_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) => {
@@ -17,6 +17,10 @@ export declare const threadStrings: {
17
17
  * It opens at the bottom, or at `anchor` (the `id` of something in it, such as
18
18
  * a chapter divider or the card that needs the reader), and then waits for the
19
19
  * reader instead of following.
20
+ *
21
+ * While the reader is scrolled up, "Jump to latest" pulses once each time a
22
+ * new message arrives (a child of the thread, or any element marked `data-slot="message"` inside it), then stays still; the jump itself scrolls smoothly
23
+ * unless the OS asks for reduced motion or data-motion is "none".
20
24
  */
21
25
  declare function Thread({ children, empty, footer, className, contentClassName, virtualize, anchor, scrollerRef, onDrop, fade, }: {
22
26
  children?: React.ReactNode | undefined;
@@ -23,6 +23,10 @@ export const threadStrings = {
23
23
  * It opens at the bottom, or at `anchor` (the `id` of something in it, such as
24
24
  * a chapter divider or the card that needs the reader), and then waits for the
25
25
  * reader instead of following.
26
+ *
27
+ * While the reader is scrolled up, "Jump to latest" pulses once each time a
28
+ * new message arrives (a child of the thread, or any element marked `data-slot="message"` inside it), then stays still; the jump itself scrolls smoothly
29
+ * unless the OS asks for reduced motion or data-motion is "none".
26
30
  */
27
31
  function Thread({ children, empty, footer, className, contentClassName, virtualize = false, anchor, scrollerRef, onDrop, fade = true, }) {
28
32
  const t = useStrings("thread", threadStrings);
@@ -35,9 +39,78 @@ function Thread({ children, empty, footer, className, contentClassName, virtuali
35
39
  React.useImperativeHandle(scrollerRef, () => scroller.current, []);
36
40
  const toBottom = React.useCallback((behavior = "auto") => {
37
41
  const el = scroller.current;
38
- if (el)
39
- el.scrollTo({ top: el.scrollHeight, behavior });
42
+ if (!el)
43
+ return;
44
+ // A smooth jump only when nothing asks for stillness: the OS setting, or data-motion="none" around the thread.
45
+ const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches || el.closest('[data-motion="none"]') !== null;
46
+ el.scrollTo({ top: el.scrollHeight, behavior: still ? "auto" : behavior });
47
+ }, []);
48
+ // A new message while the reader is scrolled up: the Jump to latest control pulses once for each arrival, even when
49
+ // the messages sit inside a wrapper component or a fragment. A second arrival while it still pulses restarts it.
50
+ const [arrived, setArrived] = React.useState(false);
51
+ const arrivedRef = React.useRef(false);
52
+ const frame = React.useRef(0);
53
+ React.useEffect(() => {
54
+ arrivedRef.current = arrived;
55
+ }, [arrived]);
56
+ React.useEffect(() => () => cancelAnimationFrame(frame.current), []);
57
+ const arrive = React.useCallback(() => {
58
+ if (pinnedRef.current)
59
+ return;
60
+ if (!arrivedRef.current) {
61
+ arrivedRef.current = true;
62
+ setArrived(true);
63
+ return;
64
+ }
65
+ // Already pulsing: switch it off and on again next frame, so the ring plays again.
66
+ arrivedRef.current = false;
67
+ setArrived(false);
68
+ cancelAnimationFrame(frame.current);
69
+ frame.current = requestAnimationFrame(() => {
70
+ arrivedRef.current = true;
71
+ setArrived(true);
72
+ });
40
73
  }, []);
74
+ // Virtualized, every child is one message and the rows come and go as the reader scrolls, so the children are counted.
75
+ const count = React.Children.count(children);
76
+ const lastCount = React.useRef(count);
77
+ React.useEffect(() => {
78
+ const grew = count > lastCount.current;
79
+ lastCount.current = count;
80
+ if (grew && virtualize)
81
+ arrive();
82
+ }, [count, virtualize, arrive]);
83
+ // Otherwise the list itself is watched. A message is an element marked data-slot="message" anywhere inside the log, or a
84
+ // direct child of it that holds none. It is an arrival only when there are more messages than before and an added one
85
+ // comes after every message that was already there: unfolding earlier turns inserts in the middle, and swapping a
86
+ // reply for another version (a keyed remount) doesn't change the count. Text streaming into a message adds neither.
87
+ React.useEffect(() => {
88
+ const el = content.current;
89
+ if (virtualize || !el || typeof MutationObserver === "undefined")
90
+ return;
91
+ const messages = () => Array.from(el.querySelectorAll("*")).filter((node) => node.matches('[data-slot="message"]') || (node.parentElement === el && node.querySelector('[data-slot="message"]') === null));
92
+ const seen = { messages: messages() };
93
+ const observer = new MutationObserver(() => {
94
+ const before = seen.messages;
95
+ const known = (seen.messages = messages());
96
+ if (known.length <= before.length)
97
+ return;
98
+ const had = new Set(before);
99
+ const survivors = before.filter((node) => node.isConnected);
100
+ const added = known.filter((node) => !had.has(node));
101
+ const atTheEnd = added.some((node) => survivors.every((old) => old.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING));
102
+ if (atTheEnd)
103
+ arrive();
104
+ });
105
+ observer.observe(el, { childList: true, subtree: true });
106
+ return () => observer.disconnect();
107
+ }, [virtualize, arrive]);
108
+ React.useEffect(() => {
109
+ if (!arrived)
110
+ return;
111
+ const timer = window.setTimeout(() => setArrived(false), 1000);
112
+ return () => window.clearTimeout(timer);
113
+ }, [arrived]);
41
114
  // Open at the anchor once, when it first appears.
42
115
  const anchored = React.useRef(false);
43
116
  React.useLayoutEffect(() => {
@@ -102,10 +175,12 @@ function Thread({ children, empty, footer, className, contentClassName, virtuali
102
175
  ? (e) => {
103
176
  setFocused(e.target.closest("[data-virtual-key]")?.getAttribute("data-virtual-key") ?? null);
104
177
  }
105
- : 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 && (_jsxs("button", { type: "button", onClick: () => {
106
- pinnedRef.current = true;
107
- setPinned(true);
108
- toBottom("smooth");
109
- }, className: "absolute bottom-3 left-1/2 z-10 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full border bg-popover px-3 py-1.5 text-xs font-medium shadow-float animate-in fade-in-0 slide-in-from-bottom-1", children: [_jsx(ArrowDown, { "aria-hidden": true, className: "size-3" }), " ", t("jumpToLatest")] })), fade && _jsx("div", { "aria-hidden": true, "data-slot": "thread-fade", className: "pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent" })] }), footer && _jsx("div", { className: "mx-auto w-full max-w-(--cd-thread-width) px-4 pb-4", children: footer })] }));
178
+ : 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 && (
179
+ // The entrance is the wrapper's, so the pulse on the button never replays it.
180
+ _jsx("span", { className: "absolute bottom-3 left-1/2 z-10 -translate-x-1/2 animate-in fade-in-0 slide-in-from-bottom-1", children: _jsxs("button", { type: "button", "data-slot": "thread-jump", "data-arrived": arrived ? "" : undefined, onClick: () => {
181
+ pinnedRef.current = true;
182
+ setPinned(true);
183
+ toBottom("smooth");
184
+ }, className: cn("inline-flex items-center gap-1.5 rounded-full border bg-popover px-3 py-1.5 text-xs font-medium shadow-float [--cd-attention-color:var(--primary)]", arrived && "animate-attention"), children: [_jsx(ArrowDown, { "aria-hidden": true, className: "size-3" }), " ", t("jumpToLatest")] }) })), fade && _jsx("div", { "aria-hidden": true, "data-slot": "thread-fade", className: "pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent" })] }), footer && _jsx("div", { className: "mx-auto w-full max-w-(--cd-thread-width) px-4 pb-4", children: footer })] }));
110
185
  }
111
186
  export { Thread };
@@ -20,7 +20,8 @@ export declare const toolCallStrings: {
20
20
  /**
21
21
  * One tool call as a compact row: what it did in plain words, its status, and
22
22
  * how long it took. Expands to the input and output. A failed call opens by
23
- * default and leads with its error.
23
+ * default and leads with its error. A call that finishes while on screen draws
24
+ * its tick or cross; one that arrives finished is drawn still.
24
25
  */
25
26
  declare function ToolCall({ call, className, }: {
26
27
  /** The call: tool name, plain-words title, status, input, output, duration or error. */
@@ -26,7 +26,8 @@ export const toolCallStrings = {
26
26
  /**
27
27
  * One tool call as a compact row: what it did in plain words, its status, and
28
28
  * how long it took. Expands to the input and output. A failed call opens by
29
- * default and leads with its error.
29
+ * default and leads with its error. A call that finishes while on screen draws
30
+ * its tick or cross; one that arrives finished is drawn still.
30
31
  */
31
32
  function ToolCall({ call, className, }) {
32
33
  const t = useStrings("toolCall", toolCallStrings);
@@ -34,7 +35,7 @@ function ToolCall({ call, className, }) {
34
35
  const failed = call.status === "failed";
35
36
  const [open, setOpen] = React.useState(failed);
36
37
  const hasDetail = Boolean(call.input || call.output || call.error || call.approval?.note || call.approval?.originalInput);
37
- return (_jsxs(Collapsible, { open: open, onOpenChange: setOpen, "data-slot": "tool-call", "data-status": call.status, className: cn("group/tool", className), children: [_jsxs(CollapsibleTrigger, { disabled: !hasDetail, className: "flex w-full items-center gap-2.5 rounded-md px-1.5 py-1 text-start outline-none transition-colors duration-150 enabled:hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80", children: [_jsx(StepMark, { status: call.status }), _jsxs("span", { className: "min-w-0 flex-1 truncate text-sm", children: [call.status === "running" ? _jsx(ShimmerLabel, { className: "font-normal", children: call.title }) : call.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel(call.status)] })] }), call.approval?.originalInput !== undefined && (_jsx("span", { "data-slot": "tool-call-edited", className: "shrink-0 rounded-xs bg-warning-weak px-1 text-2xs text-warning-text", children: t("editedBeforeRun") })), call.approval?.by === "automatic" && (_jsx("span", { "data-slot": "tool-call-approval", className: "shrink-0 rounded-xs bg-muted px-1 text-2xs text-muted-foreground", children: t("approvedAutomatically") })), _jsx("code", { className: "hidden shrink-0 font-mono text-2xs text-faint-foreground sm:inline", children: call.tool }), call.duration && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: call.duration }), hasDetail && (_jsx(ChevronRight, { "aria-hidden": true, className: "rtl:-scale-x-100 size-3.5 shrink-0 text-faint-foreground transition-transform duration-200 ease-standard group-data-[state=open]/tool:[transform:rotate(90deg)]" }))] }), _jsx(CollapsibleContent, { className: "data-[state=open]:animate-in data-[state=open]:fade-in-0", children: _jsxs("div", { className: "mt-1 mb-2 ms-[1.375rem] flex flex-col gap-2 border-s ps-3.5", children: [call.error && (_jsx("p", { className: "rounded-md bg-destructive-weak px-2.5 py-1.5 text-xs text-destructive-text", children: call.error })), call.approval?.note && (_jsxs("p", { className: "text-xs text-muted-foreground", children: [call.approval.by === "automatic" ? t("approvedAutomatically") : t("approvedByPerson"), ": ", call.approval.note] })), call.approval?.originalInput !== undefined && _jsx("p", { className: "text-xs text-muted-foreground", children: t("editedNote") }), call.input && _jsx(Payload, { language: "json", title: t("input"), text: call.input }), call.approval?.originalInput !== undefined && _jsx(Payload, { language: "json", title: t("proposed"), text: call.approval.originalInput }), call.output && _jsx(Payload, { language: call.outputLanguage ?? "text", title: t("output"), text: call.output })] }) })] }));
38
+ return (_jsxs(Collapsible, { open: open, onOpenChange: setOpen, "data-slot": "tool-call", "data-status": call.status, className: cn("group/tool", className), children: [_jsxs(CollapsibleTrigger, { disabled: !hasDetail, className: "flex w-full items-center gap-2.5 rounded-md px-1.5 py-1 text-start outline-none transition-colors duration-150 enabled:hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80", children: [_jsx(StepMark, { status: call.status }), _jsxs("span", { className: "min-w-0 flex-1 truncate text-sm", children: [call.status === "running" ? _jsx(ShimmerLabel, { className: "font-normal", children: call.title }) : call.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel(call.status)] })] }), call.approval?.originalInput !== undefined && (_jsx("span", { "data-slot": "tool-call-edited", className: "shrink-0 rounded-xs bg-warning-weak px-1 text-2xs text-warning-text", children: t("editedBeforeRun") })), call.approval?.by === "automatic" && (_jsx("span", { "data-slot": "tool-call-approval", className: "shrink-0 rounded-xs bg-muted px-1 text-2xs text-muted-foreground", children: t("approvedAutomatically") })), _jsx("code", { className: "hidden shrink-0 font-mono text-2xs text-faint-foreground sm:inline", children: call.tool }), call.duration && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: call.duration }), hasDetail && (_jsx(ChevronRight, { "aria-hidden": true, className: "rtl:-scale-x-100 size-3.5 shrink-0 text-faint-foreground transition-transform duration-200 ease-standard group-data-[state=open]/tool:[transform:rotate(90deg)]" }))] }), _jsx(CollapsibleContent, { children: _jsxs("div", { className: "mt-1 mb-2 ms-[1.375rem] flex flex-col gap-2 border-s ps-3.5", children: [call.error && (_jsx("p", { className: "rounded-md bg-destructive-weak px-2.5 py-1.5 text-xs text-destructive-text", children: call.error })), call.approval?.note && (_jsxs("p", { className: "text-xs text-muted-foreground", children: [call.approval.by === "automatic" ? t("approvedAutomatically") : t("approvedByPerson"), ": ", call.approval.note] })), call.approval?.originalInput !== undefined && _jsx("p", { className: "text-xs text-muted-foreground", children: t("editedNote") }), call.input && _jsx(Payload, { language: "json", title: t("input"), text: call.input }), call.approval?.originalInput !== undefined && _jsx(Payload, { language: "json", title: t("proposed"), text: call.approval.originalInput }), call.output && _jsx(Payload, { language: call.outputLanguage ?? "text", title: t("output"), text: call.output })] }) })] }));
38
39
  }
39
40
  /** A run of tool calls, with a count when it's long enough to fold. */
40
41
  function ToolCallGroup({ calls, className, }) {
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,9 +549,23 @@
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;
568
+ animation-delay: 0s !important;
474
569
  animation-iteration-count: 1 !important;
475
570
  transition-duration: 0.001ms !important;
476
571
  scroll-behavior: auto !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.8.0",
3
+ "version": "1.9.1",
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",