@candor-ui/react 2.3.0 → 2.5.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.
Files changed (52) hide show
  1. package/README.md +3 -3
  2. package/dist/blocks/copy-field.js +4 -1
  3. package/dist/blocks/degraded-panel.js +4 -0
  4. package/dist/blocks/locked.js +29 -3
  5. package/dist/blocks/relative-time.js +1 -1
  6. package/dist/blocks/status-dot.js +9 -2
  7. package/dist/blocks/step-list.js +16 -3
  8. package/dist/blocks/streaming-text.js +4 -0
  9. package/dist/chat/attachment-preview.d.ts +6 -1
  10. package/dist/chat/attachment-preview.js +5 -2
  11. package/dist/chat/card-registry.js +7 -3
  12. package/dist/chat/citation.d.ts +3 -0
  13. package/dist/chat/citation.js +5 -2
  14. package/dist/chat/composer.js +17 -4
  15. package/dist/chat/context-meter.d.ts +5 -0
  16. package/dist/chat/context-meter.js +13 -2
  17. package/dist/chat/entity-chip.d.ts +6 -1
  18. package/dist/chat/entity-chip.js +5 -2
  19. package/dist/chat/folded-turns.js +4 -0
  20. package/dist/chat/message-actions.js +4 -1
  21. package/dist/chat/message.js +8 -1
  22. package/dist/chat/payload.d.ts +3 -0
  23. package/dist/chat/payload.js +3 -0
  24. package/dist/chat/permission-prompt.d.ts +5 -0
  25. package/dist/chat/permission-prompt.js +31 -2
  26. package/dist/chat/prompt-suggestions.js +4 -1
  27. package/dist/chat/reasoning.js +8 -0
  28. package/dist/chat/thread.js +32 -5
  29. package/dist/chat/tool-call.js +8 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +1 -0
  32. package/dist/lib/motion.d.ts +16 -1
  33. package/dist/lib/motion.js +33 -3
  34. package/dist/lib/sound.d.ts +44 -0
  35. package/dist/lib/sound.js +63 -0
  36. package/dist/styles.css +19 -1
  37. package/dist/ui/accordion.d.ts +2 -2
  38. package/dist/ui/accordion.js +2 -2
  39. package/dist/ui/alert.d.ts +2 -1
  40. package/dist/ui/alert.js +17 -10
  41. package/dist/ui/button.js +4 -0
  42. package/dist/ui/collapsible.js +5 -0
  43. package/dist/ui/popover.js +4 -0
  44. package/dist/ui/progress.d.ts +1 -1
  45. package/dist/ui/progress.js +1 -1
  46. package/dist/ui/skeleton.js +4 -1
  47. package/dist/ui/slider.d.ts +1 -1
  48. package/dist/ui/slider.js +1 -1
  49. package/dist/ui/switch.d.ts +1 -1
  50. package/dist/ui/switch.js +1 -1
  51. package/dist/ui/tooltip.js +4 -0
  52. package/package.json +2 -3
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,7 +5,10 @@ import { cn } from "../lib/utils.js";
5
5
  import { useCopy } from "../lib/use-copy.js";
6
6
  import { useStrings } from "./locale.js";
7
7
  import { motion } from "../lib/motion.js";
8
- /** The tick or cross after the clipboard write settles. */
8
+ /**
9
+ * @motion pop:tick After the person copies, a tick (or a cross, if the browser refused the clipboard) pops in on the
10
+ * button once the browser has answered. Nothing shows before the answer, and under reduced motion it fades in.
11
+ */
9
12
  const tickMotion = motion("copy-field", "tick", "pop");
10
13
  /** CopyField's fixed words; override them under `copyField` in LocaleProvider's `strings`. */
11
14
  export const copyFieldStrings = {
@@ -5,6 +5,10 @@ import { cn } from "../lib/utils.js";
5
5
  import { Button } from "../ui/button.js";
6
6
  import { useStrings } from "./locale.js";
7
7
  import { motion } from "../lib/motion.js";
8
+ /**
9
+ * @motion live:busy While a retry is under way, the retry icon turns in place and the button says it is retrying. It
10
+ * stops when the app ends the retry, and under reduced motion the icon is still.
11
+ */
8
12
  const spinMotion = motion("degraded-panel", "busy", "live", { dur: 1000 });
9
13
  /** DegradedPanel's fixed words; override them under `degradedPanel` in LocaleProvider's `strings`. */
10
14
  export const degradedPanelStrings = {
@@ -5,8 +5,27 @@ 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
- /** A press on the locked control: the reason is emphasised, and under expressive the control shakes. */
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
+ }
18
+ /**
19
+ * @motion refuse:refuse A press on a locked control shows the reason it is locked; under the expressive setting the
20
+ * control also shakes once. The control stays locked and does nothing it would have done unlocked. Nothing plays
21
+ * until the person presses it.
22
+ */
9
23
  const refuseMotion = motion("locked", "refuse", "refuse");
24
+ /**
25
+ * @motion tint:reason When the person presses a locked control, its reason changes from muted to full-strength text
26
+ * so it can be read, and goes back to muted for a moment after the press. Screen readers always have the reason,
27
+ * whether or not it is shown.
28
+ */
10
29
  const reasonMotion = motion("locked", "reason", "tint", { dur: 200 });
11
30
  /**
12
31
  * The honest-state rule: a control that cannot be used says why.
@@ -28,10 +47,14 @@ function Locked({ reason, inline = false, children, className, }) {
28
47
  const [press, setPress] = React.useState(0);
29
48
  const [shaking, setShaking] = React.useState(false);
30
49
  const [held, setHeld] = React.useState(false);
50
+ const box = React.useRef(null);
31
51
  React.useEffect(() => {
32
52
  if (press === 0)
33
53
  return;
34
- const shake = window.setTimeout(() => setShaking(false), 400);
54
+ // The shake ends when its animation does (onAnimationEnd below). This timer is only the fallback for when no
55
+ // animation runs (motion off, or no Candor CSS), set from the duration the page resolved: a brand's longer shake,
56
+ // or the speed token, isn't cut short.
57
+ const shake = window.setTimeout(() => setShaking(false), (animationMs(box.current) ?? 400) + 100);
35
58
  const hold = window.setTimeout(() => setHeld(false), 1800);
36
59
  return () => {
37
60
  window.clearTimeout(shake);
@@ -57,6 +80,9 @@ function Locked({ reason, inline = false, children, className, }) {
57
80
  block(e);
58
81
  },
59
82
  });
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] })] }));
83
+ return (_jsxs("span", { ref: box, "data-slot": "locked", onAnimationEnd: (e) => {
84
+ if (e.target === e.currentTarget)
85
+ setShaking(false);
86
+ }, "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
87
  }
62
88
  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 };
@@ -4,9 +4,16 @@ import { cn } from "../lib/utils.js";
4
4
  import { useNow } from "./relative-time.js";
5
5
  import { motion, useSettled } from "../lib/motion.js";
6
6
  import { runLiveness, STALE_AFTER, useStatusLabel } from "../lib/status.js";
7
- /** The pulse while a run is heard from. */
7
+ /**
8
+ * @motion live:live A running or waiting dot pulses only while the run has been heard from within the allowed
9
+ * silence. Past it, or when the time can't be read, the dot is still rather than claiming the run is alive, and under
10
+ * reduced motion it is still.
11
+ */
8
12
  const liveMotion = motion("status-dot", "live", "live");
9
- /** The dot popping into its final colour when the run settles on screen. */
13
+ /**
14
+ * @motion pop:settle The dot pops into its final colour when a run it was showing as unfinished settles while it is
15
+ * on screen. A dot that is already settled when it appears is drawn in its colour at once.
16
+ */
10
17
  const settleMotion = motion("status-dot", "settle", "pop");
11
18
  const dotColor = {
12
19
  idle: "bg-faint-foreground/60",
@@ -5,12 +5,25 @@ import { CircleSlash, Hand, LoaderCircle } from "lucide-react";
5
5
  import { cn } from "../lib/utils.js";
6
6
  import { motion, useArrived, useSettled } from "../lib/motion.js";
7
7
  import { useStatusLabel } from "../lib/status.js";
8
- /** A step's tick or cross, drawn when its status settles on screen. */
8
+ /**
9
+ * @motion mark:mark A step's tick or cross is drawn along its path when the step settles while the list is on screen.
10
+ * Steps that were already settled when the list appeared show their mark at once.
11
+ */
9
12
  const markMotion = motion("step-list", "mark", "mark");
10
- /** The rail growing to a step that finished on screen. */
13
+ /**
14
+ * @motion fill:rail The line leading to a step fills in when that step finishes while the list is on screen, never
15
+ * ahead of it. Lines for steps that were already finished when the list appeared are full at once.
16
+ */
11
17
  const railMotion = motion("step-list", "rail", "fill");
12
- /** A step added after the list first drew. */
18
+ /**
19
+ * @motion arrive:arrive A step rises in when the app adds it after the list first drew. The steps the list was drawn
20
+ * with appear still.
21
+ */
13
22
  const arriveMotion = motion("step-list", "arrive", "arrive");
23
+ /**
24
+ * @motion live:working A step that is running shows a spinner that turns until the app reports it finished or failed.
25
+ * Under reduced motion the spinner is still, and the step's status is still written in words.
26
+ */
14
27
  const workingMotion = motion("step-list", "working", "live", { dur: 1000 });
15
28
  /** A tick, drawn along its path when `draw` is above zero (and again each time it changes). */
16
29
  function Tick({ draw }) {
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "../lib/utils.js";
3
3
  import { motion } from "../lib/motion.js";
4
+ /**
5
+ * @motion live:caret While the text is still streaming in, a caret blinks at its end. It goes when streaming stops,
6
+ * and under reduced motion it is a still bar.
7
+ */
4
8
  const caretMotion = motion("streaming-text", "caret", "live", { dur: 1000 });
5
9
  /**
6
10
  * Text that is still arriving. Shows a caret while `streaming`, and announces
@@ -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
@@ -6,6 +6,10 @@ import { DegradedPanel } from "../blocks/degraded-panel.js";
6
6
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
7
7
  import { useStrings } from "../blocks/locale.js";
8
8
  import { motion } from "../lib/motion.js";
9
+ /**
10
+ * @motion reveal:open The text view opens to its full height when the person asks to see the card as text, and the
11
+ * chevron turns with it; closing reverses both. Under reduced motion it snaps open or shut.
12
+ */
9
13
  const turnMotion = motion("card-registry", "open", "reveal");
10
14
  /** CardRegistry's fixed words; override them under `cardRegistry` in LocaleProvider's `strings`. */
11
15
  export const cardRegistryStrings = {
@@ -46,13 +50,13 @@ function Card({ name, props, summary, }) {
46
50
  const cards = React.useContext(Registry);
47
51
  const def = cards[name];
48
52
  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 })] }));
53
+ 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
54
  }
51
55
  const problem = def.check(props);
52
56
  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 })] }));
57
+ 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
58
  }
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) }) }));
59
+ 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
60
  }
57
61
  /* ---------------------------------------------------------------- small checking helpers */
58
62
  export const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -28,6 +28,9 @@ declare function SourceDetail({ source, }: {
28
28
  * An inline [n]. Opens the source in a popover on click or Enter, so evidence is
29
29
  * one step away without leaving the thread. A source the message doesn't list
30
30
  * renders as a plain bracket rather than a guess.
31
+ *
32
+ * @motion tint:tint A source chip's border and text colour change when the pointer is over it, and a ring fades in
33
+ * when a citation number or chip takes keyboard focus.
31
34
  */
32
35
  declare function Citation({ source, marker, }: {
33
36
  /** The source this marker points to; without it the marker says it's unknown. */
@@ -48,13 +48,16 @@ function SourceDetail({ source, }) {
48
48
  * An inline [n]. Opens the source in a popover on click or Enter, so evidence is
49
49
  * one step away without leaving the thread. A source the message doesn't list
50
50
  * renders as a plain bracket rather than a guess.
51
+ *
52
+ * @motion tint:tint A source chip's border and text colour change when the pointer is over it, and a ring fades in
53
+ * when a citation number or chip takes keyboard focus.
51
54
  */
52
55
  function Citation({ source, marker, }) {
53
56
  const t = useStrings("citation", citationStrings);
54
57
  if (!source) {
55
- return _jsxs("sup", { className: "text-faint-foreground", children: ["[", marker ?? "?", "]"] });
58
+ return _jsxs("sup", { "data-slot": "citation", className: "text-faint-foreground", children: ["[", marker ?? "?", "]"] });
56
59
  }
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
60
+ 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
61
  ? "border border-dashed border-provisional/60 text-provisional hover:bg-muted"
59
62
  : "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
63
  }
@@ -8,12 +8,25 @@ import { EntityChip, useEntityKinds } from "./entity-chip.js";
8
8
  import { AttachmentChip } from "./attachment-preview.js";
9
9
  import { useStrings } from "../blocks/locale.js";
10
10
  import { motion, useArrived, useLeaving } from "../lib/motion.js";
11
- /** A chip added after the first draw pops in; a removed one pops out as a ghost. */
11
+ /**
12
+ * @motion pop:chip A mention or attachment chip pops in when the person adds one while writing, and a chip they
13
+ * remove pops out. Chips that were there when the composer first drew, such as a restored draft, appear still.
14
+ */
12
15
  const chipMotion = motion("composer", "chip", "pop");
13
- /** The mention and command menu: 2.2's fade and 4 px rise. */
16
+ /**
17
+ * @motion overlay:menu The mention and command menu rises a little and fades in above the field when the person types
18
+ * the character that opens it, and goes at once when it closes. Under reduced motion it is a short fade.
19
+ */
14
20
  const menuMotion = motion("composer", "menu", "overlay", { variant: "slide" });
15
- /** Send and Stop acknowledge the press: 2.2's scale of 0.95. */
21
+ /**
22
+ * @motion press:send Send and Stop shrink a little while they are held down and return when let go. A Send that is
23
+ * disabled never moves, and under reduced motion neither changes size.
24
+ */
16
25
  const sendMotion = motion("composer", "send", "press", { amp: "0.9" });
26
+ /**
27
+ * @motion live:sending While the app reports that the message is being sent, a spinner turns in place of Send's
28
+ * arrow. It stops when the app says sending is over, and under reduced motion the spinner is still.
29
+ */
17
30
  const sendingMotion = motion("composer", "sending", "live", { dur: 1000 });
18
31
  /** Composer's fixed words; override them under `composer` in LocaleProvider's `strings`. */
19
32
  export const composerStrings = {
@@ -218,7 +231,7 @@ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, o
218
231
  e.preventDefault();
219
232
  choose(item);
220
233
  }, 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) => {
234
+ }) })), _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
235
  onChange(e.target.value);
223
236
  requestAnimationFrame(syncTrigger);
224
237
  }, 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
  }
@@ -43,8 +43,11 @@ declare function useEntityKinds(): (kind: string) => EntityKindInfo;
43
43
  * so "Account" the table and "Account" the report never read alike. The
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
+ *
47
+ * @motion tint:tint A chip that can be pressed changes its border and background when the pointer is over it, and a
48
+ * ring fades in on keyboard focus. A chip that can't be pressed does not change.
46
49
  */
47
- declare function EntityChip({ entity, onOpen, onRemove, className, }: {
50
+ declare function EntityChip({ entity, onOpen, onRemove, className, style, }: {
48
51
  /** The thing to show: its kind, identifier and optional label. */
49
52
  entity: EntityRef;
50
53
  /** Makes the chip a button that opens the thing. */
@@ -52,5 +55,7 @@ declare function EntityChip({ entity, onOpen, onRemove, className, }: {
52
55
  /** Adds a remove button, e.g. in the composer. */
53
56
  onRemove?: ((entity: EntityRef) => void) | undefined;
54
57
  className?: string | undefined;
58
+ /** Inline style for the chip's outer element, such as a motion chain from `motion()`. */
59
+ style?: React.CSSProperties | undefined;
55
60
  }): React.JSX.Element;
56
61
  export { EntityChip, EntityKindsProvider, useEntityKinds };
@@ -71,8 +71,11 @@ function useEntityKinds() {
71
71
  * so "Account" the table and "Account" the report never read alike. The
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
+ *
75
+ * @motion tint:tint A chip that can be pressed changes its border and background when the pointer is over it, and a
76
+ * ring fades in on keyboard focus. A chip that can't be pressed does not change.
74
77
  */
75
- function EntityChip({ entity, onOpen, onRemove, className, }) {
78
+ function EntityChip({ entity, onOpen, onRemove, className, style, }) {
76
79
  const { Icon, noun } = useEntityKinds()(entity.kind);
77
80
  const t = useStrings("entityChip", entityChipStrings);
78
81
  const shown = entity.label ?? entity.name;
@@ -81,6 +84,6 @@ function EntityChip({ entity, onOpen, onRemove, className, }) {
81
84
  : t("title", { noun, name: entity.name });
82
85
  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
86
  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" }))] }));
87
+ 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
88
  }
86
89
  export { EntityChip, EntityKindsProvider, useEntityKinds };
@@ -6,6 +6,10 @@ import { cn } from "../lib/utils.js";
6
6
  import { useStrings } from "../blocks/locale.js";
7
7
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
8
8
  import { motion } from "../lib/motion.js";
9
+ /**
10
+ * @motion reveal:open The folded turns open to their full height when the person presses the summary, with its
11
+ * chevron turning, and close the same way. Under reduced motion they snap open or shut.
12
+ */
9
13
  const turnMotion = motion("folded-turns", "open", "reveal");
10
14
  /** FoldedTurns' fixed words; override them under `foldedTurns` in LocaleProvider's `strings`. */
11
15
  export const foldedTurnsStrings = {
@@ -7,7 +7,10 @@ import { useCopy } from "../lib/use-copy.js";
7
7
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
8
8
  import { useStrings } from "../blocks/locale.js";
9
9
  import { motion } from "../lib/motion.js";
10
- /** The tick or cross after the clipboard write settles. */
10
+ /**
11
+ * @motion pop:tick After the person copies, a tick (or a cross, if the browser refused the clipboard) pops in on the
12
+ * button once the browser has answered. Nothing shows before the answer, and under reduced motion it fades in.
13
+ */
11
14
  const tickMotion = motion("message-actions", "tick", "pop");
12
15
  /** MessageActions' fixed words; override them under `messageActions` in LocaleProvider's `strings`. */
13
16
  export const messageActionsStrings = {
@@ -12,8 +12,15 @@ import { Reasoning } from "./reasoning.js";
12
12
  import { ThreadEvent } from "./thread-event.js";
13
13
  import { useStrings } from "../blocks/locale.js";
14
14
  import { motion } from "../lib/motion.js";
15
- /** A message that arrived after the thread first drew rises in (Thread marks it `data-arrived`); loaded history is still. */
15
+ /**
16
+ * @motion arrive:arrive A message rises in when it is added to a Thread after the thread first drew, whether it is
17
+ * the person's or the agent's. Messages loaded with the thread, and a Message outside a Thread, are drawn still.
18
+ */
16
19
  const arriveMotion = motion("message", "arrive", "arrive");
20
+ /**
21
+ * @motion live:caret While a reply is still streaming, a caret blinks at the end of its text. It goes when the reply
22
+ * finishes or is interrupted, and under reduced motion it is a still bar.
23
+ */
17
24
  const caretMotion = motion("message", "caret", "live", { dur: 1000 });
18
25
  /** Message's fixed words; override them under `message` in LocaleProvider's `strings`. */
19
26
  export const messageStrings = {
@@ -16,6 +16,9 @@ export declare const payloadStrings: {
16
16
  *
17
17
  * Code is split into tokens (keywords, strings, comments…) rendered as spans of text, never as HTML. They're coloured
18
18
  * when `highlight` is set, or anywhere under `data-syntax="highlight"` (set it on `<html>` for the whole app).
19
+ *
20
+ * @motion tint:tint The copy button's background and text colour change when the pointer is over it, and a ring fades
21
+ * in when it takes keyboard focus.
19
22
  */
20
23
  declare function Payload({ language, text, title, bare, highlight, className, }: {
21
24
  /** Shown as the label, e.g. json, sql, python. Never executed or highlighted as HTML. */
@@ -32,6 +32,9 @@ const languageLabel = (lang, t) => {
32
32
  *
33
33
  * Code is split into tokens (keywords, strings, comments…) rendered as spans of text, never as HTML. They're coloured
34
34
  * when `highlight` is set, or anywhere under `data-syntax="highlight"` (set it on `<html>` for the whole app).
35
+ *
36
+ * @motion tint:tint The copy button's background and text colour change when the pointer is over it, and a ring fades
37
+ * in when it takes keyboard focus.
35
38
  */
36
39
  function Payload({ language, text, title, bare = false, highlight, className, }) {
37
40
  const { state, copy } = useCopy();
@@ -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. */