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