@darqlabs/curator-sdk-react 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,8 +17,9 @@ import { createCuratorAdapter } from "./CuratorAdapter.js";
17
17
  import { PoweredByCurator } from "./brand.js";
18
18
  import { Markdown } from "./Markdown.js";
19
19
  import { splitAttachmentEnvelope, formatAttachmentSize } from "./attachmentEnvelope.js";
20
+ import { CuratorAttachmentAdapter } from "./CuratorAttachmentAdapter.js";
20
21
  export function CuratorChat(props) {
21
- const { curator, agent, project, environment, system, conversationId, title = "Chat", placeholder = "Type a message…", welcomeMessage, theme = "light", className, style, onClose, loadingIndicator, onMessage, onState, onPlanProgress, onError, enableRetry = false, enableCopy = false, showTimestamps = true, queueWhileRunning = true, } = props;
22
+ const { curator, agent, project, environment, system, conversationId, title = "Chat", placeholder = "Type a message…", welcomeMessage, theme = "light", className, style, onClose, loadingIndicator, onMessage, onState, onPlanProgress, onError, enableRetry = false, enableCopy = false, showTimestamps = true, enableAttachments = false, hideHeader = false, prompts, tokens, acceptedFileTypes, maxFileSize, queueWhileRunning = true, } = props;
22
23
  const contextClient = useCuratorOrNull();
23
24
  const client = curator ?? contextClient;
24
25
  if (!client) {
@@ -31,8 +32,17 @@ export function CuratorChat(props) {
31
32
  const a = client.agent(agent, { project, environment, system });
32
33
  return conversationId ? a.conversation(conversationId) : a.chat();
33
34
  }, [client, agent, project, environment, system, conversationId]);
34
- const adapter = useMemo(() => createCuratorAdapter(chat, { onMessage, onState, onPlanProgress, onError }), [chat, onMessage, onState, onPlanProgress, onError]);
35
- const runtime = useLocalRuntime(adapter);
35
+ // Rebuilt with the chat handle: the upload map is per-conversation, and a
36
+ // handle swap means the pending references belong to a transcript that is no
37
+ // longer on screen.
38
+ const attachmentAdapter = useMemo(() => enableAttachments
39
+ ? new CuratorAttachmentAdapter(client, {
40
+ accept: acceptedFileTypes,
41
+ maxFileSize,
42
+ })
43
+ : undefined, [client, chat, enableAttachments, acceptedFileTypes, maxFileSize]);
44
+ const adapter = useMemo(() => createCuratorAdapter(chat, { onMessage, onState, onPlanProgress, onError }, attachmentAdapter), [chat, onMessage, onState, onPlanProgress, onError, attachmentAdapter]);
45
+ const runtime = useLocalRuntime(adapter, attachmentAdapter ? { adapters: { attachments: attachmentAdapter } } : undefined);
36
46
  // Branding is a server-resolved entitlement (org subscription tier), not a
37
47
  // client prop — the widget runs on the customer's own page, so a local
38
48
  // boolean would be trivially flippable. We fetch the deployment config on
@@ -59,12 +69,21 @@ export function CuratorChat(props) {
59
69
  cancelled = true;
60
70
  };
61
71
  }, [client, agent, project, environment, system]);
72
+ // Order is the contract: non-colour defaults, then the theme's palette, then
73
+ // the consumer's tokens, then the raw `style` prop — each able to override
74
+ // everything before it, with `style` still winning as it always has.
62
75
  const palette = theme === "dark" ? DARK_VARS : LIGHT_VARS;
63
- const wrapperStyle = { ...palette, ...containerStyle, ...style };
64
- return (_jsxs("div", { className: className, style: wrapperStyle, "data-curator-chat": true, "data-theme": theme, children: [_jsx(Header, { title: title, onClose: onClose }), _jsx(LoadingIndicatorContext.Provider, { value: loadingIndicator ?? _jsx(TypingDots, {}), children: _jsx(RetryEnabledContext.Provider, { value: enableRetry, children: _jsx(CopyEnabledContext.Provider, { value: enableCopy, children: _jsx(TimestampsEnabledContext.Provider, { value: showTimestamps, children: _jsx(AssistantRuntimeProvider, { runtime: runtime, children: _jsxs(ThreadPrimitive.Root, { style: threadRootStyle, children: [_jsxs(ThreadPrimitive.Viewport, { autoScroll: true, style: viewportStyle, children: [_jsx(ThreadPrimitive.Empty, { children: welcomeMessage ? _jsx(Welcome, { text: welcomeMessage }) : null }), _jsx(ThreadPrimitive.Messages, { components: {
76
+ const wrapperStyle = {
77
+ ...BASE_TOKENS,
78
+ ...palette,
79
+ ...tokensToVars(tokens),
80
+ ...containerStyle,
81
+ ...style,
82
+ };
83
+ return (_jsxs("div", { className: className, style: wrapperStyle, "data-curator-chat": true, "data-theme": theme, children: [!hideHeader && _jsx(Header, { title: title, onClose: onClose }), _jsx(LoadingIndicatorContext.Provider, { value: loadingIndicator ?? _jsx(TypingDots, {}), children: _jsx(RetryEnabledContext.Provider, { value: enableRetry, children: _jsx(CopyEnabledContext.Provider, { value: enableCopy, children: _jsx(TimestampsEnabledContext.Provider, { value: showTimestamps, children: _jsx(AssistantRuntimeProvider, { runtime: runtime, children: _jsxs(ThreadPrimitive.Root, { "data-curator-part": "thread", style: threadRootStyle, children: [_jsxs(ThreadPrimitive.Viewport, { autoScroll: true, "data-curator-part": "viewport", style: viewportStyle, children: [_jsxs(ThreadPrimitive.Empty, { children: [welcomeMessage ? _jsx(Welcome, { text: welcomeMessage }) : null, prompts && prompts.length > 0 && _jsx(Prompts, { prompts: prompts })] }), _jsx(ThreadPrimitive.Messages, { components: {
65
84
  UserMessage,
66
85
  AssistantMessage,
67
- } })] }), _jsx(Composer, { placeholder: placeholder, allowQueue: queueWhileRunning })] }) }) }) }) }) }), hidePoweredBy === false && _jsx(PoweredByCurator, {})] }));
86
+ } })] }), _jsx(Composer, { placeholder: placeholder, allowQueue: queueWhileRunning, allowAttachments: !!attachmentAdapter })] }) }) }) }) }) }), hidePoweredBy === false && _jsx(PoweredByCurator, {})] }));
68
87
  }
69
88
  // Context for threading the loading indicator into the message-component
70
89
  // callbacks without remounting them on every render. Default is rendered
@@ -79,14 +98,44 @@ const CopyEnabledContext = createContext(false);
79
98
  const TimestampsEnabledContext = createContext(true);
80
99
  // ── header ──────────────────────────────────────────────────────
81
100
  function Header({ title, onClose }) {
82
- return (_jsxs("header", { style: headerStyle, children: [_jsx("h2", { style: titleStyle, children: title }), onClose && (_jsx("button", { type: "button", onClick: onClose, "aria-label": "Close chat", style: headerActionStyle, children: _jsx(HeaderCloseGlyph, {}) }))] }));
101
+ return (_jsxs("header", { "data-curator-part": "header", style: headerStyle, children: [_jsx("h2", { "data-curator-part": "title", style: titleStyle, children: title }), onClose && (_jsx("button", { type: "button", onClick: onClose, "aria-label": "Close chat", "data-curator-part": "header-action", style: headerActionStyle, children: _jsx(HeaderCloseGlyph, {}) }))] }));
83
102
  }
84
103
  // ── message primitives (custom UI on top of assistant-ui state) ─
85
104
  function Welcome({ text }) {
86
- return _jsx("div", { style: welcomeStyle, children: text });
105
+ return _jsx("div", { "data-curator-part": "welcome", style: welcomeStyle, children: text });
106
+ }
107
+ /**
108
+ * Starter prompts for an empty thread.
109
+ *
110
+ * Built on `ThreadPrimitive.Suggestion` rather than a plain button so the send
111
+ * goes through the same path as the composer's — including the case where one
112
+ * is clicked while a run is already in flight, which the primitive queues
113
+ * instead of dropping.
114
+ */
115
+ function Prompts({ prompts }) {
116
+ return (_jsx("div", { "data-curator-part": "prompts", style: promptsStyle, children: prompts.map((p, i) => {
117
+ const label = typeof p === "string" ? p : p.label;
118
+ const prompt = typeof p === "string" ? p : (p.prompt ?? p.label);
119
+ return (_jsx(ThreadPrimitive.Suggestion, { prompt: prompt, send: true, "data-curator-part": "prompt", style: promptStyle,
120
+ // The message differs from the button when the object form is used,
121
+ // so the full text has to be reachable without clicking.
122
+ title: prompt !== label ? prompt : undefined, children: label }, `${label}-${i}`));
123
+ }) }));
87
124
  }
88
125
  function UserMessage() {
89
- return (_jsxs(MessagePrimitive.Root, { style: rowEndStyle, children: [_jsx("div", { style: { ...bubbleBaseStyle, ...userBubbleStyle }, children: _jsx(MessagePrimitive.Parts, { components: { Text: UserTextPart } }) }), _jsx(Timestamp, { align: "end" })] }));
126
+ // Attachments as the message carries them structurally. The envelope path in
127
+ // `UserTextPart` is the fallback for transcripts written before the server
128
+ // stored them as a field; both render the same chip.
129
+ const attachments = useAuiState((s) => s.message?.attachments) ?? [];
130
+ const hasText = useAuiState((s) => s.message?.content?.some((p) => p.type === "text" && p.text.length > 0) ?? false);
131
+ return (_jsxs(MessagePrimitive.Root, { "data-curator-part": "message message-user", style: rowEndStyle, children: [_jsxs("div", { "data-curator-part": "bubble bubble-user", style: { ...bubbleBaseStyle, ...userBubbleStyle }, children: [attachments.map((a, i) => (_jsx(SentAttachmentChip, { name: a.name,
132
+ // A file sent with no caption is the common case; the chip is then
133
+ // the whole bubble and must not carry a top margin.
134
+ first: i === 0 && !hasText }, a.id))), _jsx(MessagePrimitive.Parts, { components: { Text: UserTextPart } })] }), _jsx(Timestamp, { align: "end" })] }));
135
+ }
136
+ /** One attachment on a sent message. Shared by the structural and envelope paths. */
137
+ function SentAttachmentChip({ name, size, mimeType, first, }) {
138
+ return (_jsxs("span", { "data-curator-part": "attachment-chip", style: { ...attachmentChipStyle, marginTop: first ? 0 : 6 }, title: mimeType ? `${name} (${mimeType})` : name, children: [_jsx(PaperclipGlyph, {}), _jsx("span", { "data-curator-part": "attachment-name", style: attachmentNameStyle, children: name }), size !== undefined && size > 0 && (_jsx("span", { "data-curator-part": "attachment-meta", style: attachmentMetaStyle, children: formatAttachmentSize(size) }))] }));
90
139
  }
91
140
  /**
92
141
  * Local-time stamp under a message.
@@ -104,7 +153,7 @@ function Timestamp({ align }) {
104
153
  const date = createdAt instanceof Date ? createdAt : new Date(createdAt);
105
154
  if (Number.isNaN(date.getTime()))
106
155
  return null;
107
- return (_jsx("time", { dateTime: date.toISOString(), title: date.toLocaleString(), style: {
156
+ return (_jsx("time", { "data-curator-part": "timestamp", dateTime: date.toISOString(), title: date.toLocaleString(), style: {
108
157
  ...timestampStyle,
109
158
  alignSelf: align === "end" ? "flex-end" : "flex-start",
110
159
  }, children: date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }) }));
@@ -115,10 +164,7 @@ const UserTextPart = ({ text }) => {
115
164
  // in the message, but showing it to the person who sent the message is a leak
116
165
  // and it is what renders as a wall of JSON. See attachmentEnvelope.ts.
117
166
  const { text: visible, attachments } = splitAttachmentEnvelope(text);
118
- return (_jsxs(_Fragment, { children: [visible, attachments.map((a, i) => (_jsxs("span", { style: { ...attachmentChipStyle, marginTop: visible || i > 0 ? 6 : 0 },
119
- // The name can be long; the tooltip gives the full value without
120
- // widening the bubble.
121
- title: `${a.filename} (${a.mimeType})`, children: [_jsx(PaperclipGlyph, {}), _jsx("span", { style: attachmentNameStyle, children: a.filename }), a.size > 0 && (_jsx("span", { style: attachmentMetaStyle, children: formatAttachmentSize(a.size) }))] }, `${a.filename}-${i}`)))] }));
167
+ return (_jsxs(_Fragment, { children: [visible, attachments.map((a, i) => (_jsx(SentAttachmentChip, { name: a.filename, size: a.size, mimeType: a.mimeType, first: !visible && i === 0 }, `${a.filename}-${i}`)))] }));
122
168
  };
123
169
  /** Small paperclip, sized to the chip's line-height. */
124
170
  function PaperclipGlyph() {
@@ -128,10 +174,10 @@ function AssistantMessage() {
128
174
  const indicator = useContext(LoadingIndicatorContext);
129
175
  const retryEnabled = useContext(RetryEnabledContext);
130
176
  const copyEnabled = useContext(CopyEnabledContext);
131
- return (_jsxs(MessagePrimitive.Root, { style: rowStartStyle, children: [_jsxs("div", { style: { ...bubbleBaseStyle, ...assistantBubbleStyle }, children: [_jsx(MessagePrimitive.If, { hasContent: false, children: indicator }), _jsx(MessagePrimitive.Parts, { components: { Text: AssistantTextPart } })] }), _jsx(Timestamp, { align: "start" }), (retryEnabled || copyEnabled) && (
177
+ return (_jsxs(MessagePrimitive.Root, { "data-curator-part": "message message-assistant", style: rowStartStyle, children: [_jsxs("div", { "data-curator-part": "bubble bubble-assistant", style: { ...bubbleBaseStyle, ...assistantBubbleStyle }, children: [_jsx(MessagePrimitive.If, { hasContent: false, children: indicator }), _jsx(MessagePrimitive.Parts, { components: { Text: AssistantTextPart } })] }), _jsx(Timestamp, { align: "start" }), (retryEnabled || copyEnabled) && (
132
178
  // `autohide="not-last"` shows the bar only on the trailing assistant
133
179
  // message; `hideWhenRunning` keeps it away while a reply streams.
134
- _jsxs(ActionBarPrimitive.Root, { autohide: "not-last", hideWhenRunning: true, style: actionBarStyle, children: [copyEnabled && (_jsxs(ActionBarPrimitive.Copy, { "aria-label": "Copy", style: actionButtonStyle, children: [_jsx(MessagePrimitive.If, { copied: true, children: _jsx(CheckGlyph, {}) }), _jsx(MessagePrimitive.If, { copied: false, children: _jsx(CopyGlyph, {}) })] })), retryEnabled && (_jsx(ActionBarPrimitive.Reload, { "aria-label": "Retry", style: actionButtonStyle, children: _jsx(RetryGlyph, {}) }))] }))] }));
180
+ _jsxs(ActionBarPrimitive.Root, { autohide: "not-last", hideWhenRunning: true, "data-curator-part": "action-bar", style: actionBarStyle, children: [copyEnabled && (_jsxs(ActionBarPrimitive.Copy, { "aria-label": "Copy", "data-curator-part": "action-button", style: actionButtonStyle, children: [_jsx(MessagePrimitive.If, { copied: true, children: _jsx(CheckGlyph, {}) }), _jsx(MessagePrimitive.If, { copied: false, children: _jsx(CopyGlyph, {}) })] })), retryEnabled && (_jsx(ActionBarPrimitive.Reload, { "aria-label": "Retry", "data-curator-part": "action-button", style: actionButtonStyle, children: _jsx(RetryGlyph, {}) }))] }))] }));
135
181
  }
136
182
  /**
137
183
  * Waiting state for a reply that has not started streaming.
@@ -143,7 +189,7 @@ function AssistantMessage() {
143
189
  * the indicator still says "busy" without the movement.
144
190
  */
145
191
  function TypingDots() {
146
- return (_jsxs("span", { style: typingDotsStyle, role: "status", "aria-label": "Assistant is thinking", children: [_jsx(Dot, { delay: 0 }), _jsx(Dot, { delay: 160 }), _jsx(Dot, { delay: 320 })] }));
192
+ return (_jsxs("span", { "data-curator-part": "typing-dots", style: typingDotsStyle, role: "status", "aria-label": "Assistant is thinking", children: [_jsx(Dot, { delay: 0 }), _jsx(Dot, { delay: 160 }), _jsx(Dot, { delay: 320 })] }));
147
193
  }
148
194
  function Dot({ delay }) {
149
195
  return (_jsx("span", { className: "curator-dot", style: {
@@ -158,12 +204,18 @@ function Dot({ delay }) {
158
204
  }
159
205
  const AssistantTextPart = ({ text }) => (_jsx(Markdown, { content: text }));
160
206
  // ── composer ────────────────────────────────────────────────────
161
- function Composer({ placeholder, allowQueue, }) {
207
+ function Composer({ placeholder, allowQueue, allowAttachments, }) {
162
208
  const aui = useAui();
163
209
  const isRunning = useAuiState((s) => s.thread.isRunning);
210
+ // Composer attachments live in assistant-ui's state, not ours — the runtime
211
+ // owns the upload lifecycle, so reading it here keeps one source of truth.
212
+ // State is read-only here; mutations go through `aui.thread.composer()`,
213
+ // which is a factory rather than an object — hence the call.
214
+ const attachments = useAuiState((s) => s.composer?.attachments) ?? [];
164
215
  const [text, setText] = useState("");
165
216
  const [queued, setQueued] = useState(null);
166
217
  const taRef = useRef(null);
218
+ const fileRef = useRef(null);
167
219
  // Guards the window between dispatching a queued message and `isRunning`
168
220
  // flipping back to true. Without it the flush effect sees "not running" on
169
221
  // the very next render and sends the same text again.
@@ -186,7 +238,9 @@ function Composer({ placeholder, allowQueue, }) {
186
238
  }, [isRunning, queued, send]);
187
239
  const submit = useCallback(() => {
188
240
  const value = text.trim();
189
- if (!value)
241
+ // An attachment with no caption is a complete message. Requiring text here
242
+ // is what made a dropped document impossible to send.
243
+ if (!value && attachments.length === 0)
190
244
  return;
191
245
  setText("");
192
246
  if (taRef.current)
@@ -199,7 +253,7 @@ function Composer({ placeholder, allowQueue, }) {
199
253
  return;
200
254
  }
201
255
  send(value);
202
- }, [text, isRunning, allowQueue, send]);
256
+ }, [text, attachments.length, isRunning, allowQueue, send]);
203
257
  const onKeyDown = (e) => {
204
258
  if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
205
259
  e.preventDefault();
@@ -215,15 +269,41 @@ function Composer({ placeholder, allowQueue, }) {
215
269
  // `disabled` is never set on the input: being able to type mid-reply is the
216
270
  // point. Send is disabled only when there is nothing to send, or when a
217
271
  // message is already held.
218
- const canSubmit = text.trim().length > 0 && !(isRunning && allowQueue && queued !== null);
219
- return (_jsxs("div", { style: composerWrapStyle, children: [queued !== null && (_jsxs("div", { style: queuedRowStyle, children: [_jsx("span", { style: queuedLabelStyle, children: "Sending next" }), _jsx("span", { style: queuedTextStyle, title: queued, children: queued }), _jsx("button", { type: "button", onClick: () => setQueued(null), "aria-label": "Cancel queued message", style: queuedCancelStyle, children: _jsx(HeaderCloseGlyph, {}) })] })), _jsxs("div", { style: composerRootStyle, children: [_jsx("textarea", { ref: (el) => {
272
+ const canSubmit = (text.trim().length > 0 || attachments.length > 0) &&
273
+ !(isRunning && allowQueue && queued !== null);
274
+ const onPickFiles = async (files) => {
275
+ if (!files)
276
+ return;
277
+ // Sequential rather than parallel: `addAttachment` mutates composer state,
278
+ // and the adapter's per-file rejection message is easier to attribute when
279
+ // they arrive in order.
280
+ for (const file of Array.from(files)) {
281
+ try {
282
+ await aui.thread.composer().addAttachment(file);
283
+ }
284
+ catch {
285
+ // The adapter reports rejection on the chip; a throw here would take
286
+ // the rest of the selection down with it.
287
+ }
288
+ }
289
+ if (fileRef.current)
290
+ fileRef.current.value = "";
291
+ };
292
+ return (_jsxs("div", { "data-curator-part": "composer", style: composerWrapStyle, children: [queued !== null && (_jsxs("div", { "data-curator-part": "queued-row", style: queuedRowStyle, children: [_jsx("span", { "data-curator-part": "queued-label", style: queuedLabelStyle, children: "Sending next" }), _jsx("span", { "data-curator-part": "queued-text", style: queuedTextStyle, title: queued, children: queued }), _jsx("button", { type: "button", onClick: () => setQueued(null), "aria-label": "Cancel queued message", "data-curator-part": "queued-cancel", style: queuedCancelStyle, children: _jsx(HeaderCloseGlyph, {}) })] })), attachments.length > 0 && (_jsx("div", { "data-curator-part": "pending-attachments", style: pendingRowStyle, children: attachments.map((a) => {
293
+ const failed = a.status?.type === "incomplete";
294
+ const uploading = a.status?.type === "running";
295
+ return (_jsxs("span", { "data-curator-part": "attachment-chip attachment-chip-pending", "data-curator-status": a.status?.type, style: {
296
+ ...attachmentChipStyle,
297
+ ...(failed ? { color: "var(--curator-error)" } : {}),
298
+ }, title: a.status?.type === "incomplete" ? a.status.message ?? a.name : a.name, children: [_jsx(PaperclipGlyph, {}), _jsx("span", { "data-curator-part": "attachment-name", style: attachmentNameStyle, children: a.name }), uploading && _jsx("span", { "data-curator-part": "attachment-meta", style: attachmentMetaStyle, children: "uploading\u2026" }), failed && _jsx("span", { "data-curator-part": "attachment-meta", style: attachmentMetaStyle, children: "failed" }), _jsx("button", { type: "button", onClick: () => void aui.thread.composer().attachment({ id: a.id }).remove(), "aria-label": `Remove ${a.name}`, "data-curator-part": "chip-remove", style: chipRemoveStyle, children: _jsx(HeaderCloseGlyph, {}) })] }, a.id));
299
+ }) })), _jsxs("div", { "data-curator-part": "composer-root", style: composerRootStyle, children: [allowAttachments && (_jsxs(_Fragment, { children: [_jsx("input", { ref: fileRef, type: "file", multiple: true, onChange: (e) => void onPickFiles(e.target.files), style: { display: "none" }, "aria-hidden": "true", tabIndex: -1 }), _jsx("button", { type: "button", onClick: () => fileRef.current?.click(), "aria-label": "Attach a file", "data-curator-part": "attach-button", style: attachButtonStyle, children: _jsx(PaperclipGlyph, {}) })] })), _jsx("textarea", { ref: (el) => {
220
300
  taRef.current = el;
221
301
  if (el)
222
302
  autosize(el);
223
303
  }, value: text, onChange: (e) => {
224
304
  setText(e.target.value);
225
305
  autosize(e.target);
226
- }, onKeyDown: onKeyDown, placeholder: placeholder, rows: 1, style: textareaStyle, "aria-label": "Message" }), _jsx("button", { type: "button", onClick: submit, disabled: !canSubmit, "aria-label": isRunning && allowQueue ? "Queue message" : "Send", style: sendButtonStyle, children: _jsx(SendGlyph, {}) })] })] }));
306
+ }, onKeyDown: onKeyDown, placeholder: placeholder, rows: 1, "data-curator-part": "composer-input", style: textareaStyle, "aria-label": "Message" }), _jsx("button", { type: "button", onClick: submit, disabled: !canSubmit, "aria-label": isRunning && allowQueue ? "Queue message" : "Send", "data-curator-part": "send-button", style: sendButtonStyle, children: _jsx(SendGlyph, {}) })] })] }));
227
307
  }
228
308
  // ── glyphs ──────────────────────────────────────────────────────
229
309
  function HeaderCloseGlyph() {
@@ -242,16 +322,126 @@ function CheckGlyph() {
242
322
  return (_jsx("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "m5 13 4 4L19 7", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }));
243
323
  }
244
324
  // ── design tokens ───────────────────────────────────────────────
245
- const SPACE_2 = 8;
246
- const SPACE_3 = 12;
247
- const SPACE_4 = 16;
248
- const FONT_BASE = 14;
249
- const FONT_SM = 12;
325
+ /**
326
+ * Every value below is a CSS variable reference, not a number.
327
+ *
328
+ * They used to be module constants baked into 27 inline style objects, which
329
+ * made typography, spacing and two of the three radii unreachable from outside
330
+ * the package: a widget whose font did not match the surrounding site could
331
+ * only be fixed by forking. Referencing variables here means the defaults ship
332
+ * in `BASE_TOKENS` and a consumer overrides any of them — via the `tokens`
333
+ * prop, or plain CSS on `[data-curator-chat]`.
334
+ *
335
+ * The `var(--x, fallback)` form is deliberate: the widget still renders
336
+ * correctly if the container's variables are stripped by an aggressive CSS
337
+ * reset on the host page.
338
+ */
339
+ const SPACE_2 = "var(--curator-space-2, 8px)";
340
+ const SPACE_3 = "var(--curator-space-3, 12px)";
341
+ const SPACE_4 = "var(--curator-space-4, 16px)";
342
+ const FONT_BASE = "var(--curator-font-size, 14px)";
343
+ const FONT_SM = "var(--curator-font-size-sm, 12px)";
250
344
  /** Timestamps only — small enough to recede, large enough to stay legible. */
251
- const FONT_XS = 11;
252
- const RADIUS_LG = 14;
253
- const RADIUS_MD = 10;
254
- const RADIUS_SM = 8;
345
+ const FONT_XS = "var(--curator-font-size-xs, 11px)";
346
+ const RADIUS_LG = "var(--curator-radius, 14px)";
347
+ const RADIUS_MD = "var(--curator-radius-md, 10px)";
348
+ const RADIUS_SM = "var(--curator-radius-sm, 8px)";
349
+ /**
350
+ * Defaults for everything that is not a colour. Colours live in the palettes
351
+ * below because they are the one axis that genuinely differs between light and
352
+ * dark; these do not.
353
+ */
354
+ const BASE_TOKENS = {
355
+ ["--curator-font-family"]: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
356
+ ["--curator-font-size"]: "14px",
357
+ ["--curator-font-size-sm"]: "12px",
358
+ ["--curator-font-size-xs"]: "11px",
359
+ ["--curator-line-height"]: "1.5",
360
+ ["--curator-font-weight-strong"]: "600",
361
+ ["--curator-space-1"]: "4px",
362
+ ["--curator-space-2"]: "8px",
363
+ ["--curator-space-3"]: "12px",
364
+ ["--curator-space-4"]: "16px",
365
+ ["--curator-radius-md"]: "10px",
366
+ ["--curator-radius-sm"]: "8px",
367
+ };
368
+ /**
369
+ * Consumer-facing token names, mapped to the variables they set.
370
+ *
371
+ * Complete for the axes a consumer can be expected to want: the whole type
372
+ * scale, the whole spacing scale, every colour surface, and the radii. An
373
+ * earlier version exposed only `fontSize` and `radius`, which split each scale
374
+ * down the middle — half reachable by prop, half only by stylesheet. A partial
375
+ * scale is worse than none, because it lets someone change a base size and then
376
+ * discover the secondary sizes did not follow.
377
+ */
378
+ const TOKEN_VARS = {
379
+ fontFamily: "--curator-font-family",
380
+ fontSize: "--curator-font-size",
381
+ // The rest of the type scale. Exposing `fontSize` alone let a host set a base
382
+ // size while the secondary sizes stayed pinned at 12/11px, which is a worse
383
+ // mismatch than not being able to change any of them.
384
+ fontSizeSm: "--curator-font-size-sm",
385
+ fontSizeXs: "--curator-font-size-xs",
386
+ fontWeightStrong: "--curator-font-weight-strong",
387
+ lineHeight: "--curator-line-height",
388
+ // Density. Every padding and gap in the widget derives from these four.
389
+ space1: "--curator-space-1",
390
+ space2: "--curator-space-2",
391
+ space3: "--curator-space-3",
392
+ space4: "--curator-space-4",
393
+ radius: "--curator-radius",
394
+ radiusMd: "--curator-radius-md",
395
+ radiusSm: "--curator-radius-sm",
396
+ background: "--curator-bg",
397
+ surface: "--curator-surface",
398
+ text: "--curator-text",
399
+ muted: "--curator-muted",
400
+ border: "--curator-border",
401
+ accent: "--curator-accent",
402
+ accentText: "--curator-accent-text",
403
+ userBubble: "--curator-user-bubble",
404
+ userBubbleText: "--curator-user-bubble-text",
405
+ assistantBubble: "--curator-assistant-bubble",
406
+ assistantBubbleText: "--curator-assistant-bubble-text",
407
+ headerBackground: "--curator-header-bg",
408
+ composerBackground: "--curator-composer-bg",
409
+ focusRing: "--curator-focus-ring",
410
+ error: "--curator-error",
411
+ };
412
+ /**
413
+ * Turn a `tokens` object into inline custom properties.
414
+ *
415
+ * Numbers become `px` because every numeric token here is a length, and
416
+ * `radius: 8` reads better at the call site than `radius: "8px"`. A string is
417
+ * passed through untouched so `radius: "0.5rem"` still works.
418
+ */
419
+ function tokensToVars(tokens) {
420
+ if (!tokens)
421
+ return {};
422
+ const out = {};
423
+ for (const [key, value] of Object.entries(tokens)) {
424
+ const cssVar = TOKEN_VARS[key];
425
+ if (!cssVar || value === undefined)
426
+ continue;
427
+ out[cssVar] = typeof value === "number" ? `${value}px` : value;
428
+ }
429
+ return out;
430
+ }
431
+ /**
432
+ * Accent on the USER bubble, neutral on the assistant's.
433
+ *
434
+ * The reverse of what this widget shipped with, and the reverse of what the
435
+ * variable names imply — the assistant bubble used to borrow `--curator-accent`
436
+ * directly. Two problems with that. The assistant writes most of the words, so
437
+ * painting its bubble in the accent colour turns every substantial reply into a
438
+ * slab of saturated colour and makes the machine the loudest thing on screen.
439
+ * And it inverts the convention every messaging UI has taught people to read,
440
+ * where the accent marks *your* messages.
441
+ *
442
+ * Both are now separate variables, so a consumer who preferred the old
443
+ * arrangement sets `assistantBubble` / `userBubble` back.
444
+ */
255
445
  const LIGHT_VARS = {
256
446
  ["--curator-bg"]: "#ffffff",
257
447
  ["--curator-surface"]: "#f8fafc",
@@ -260,9 +450,15 @@ const LIGHT_VARS = {
260
450
  ["--curator-border"]: "rgba(15, 23, 42, 0.08)",
261
451
  ["--curator-accent"]: "#0f172a",
262
452
  ["--curator-accent-text"]: "#ffffff",
263
- ["--curator-user-bubble"]: "#f1f5f9",
453
+ ["--curator-user-bubble"]: "#0f172a",
454
+ ["--curator-user-bubble-text"]: "#ffffff",
455
+ ["--curator-assistant-bubble"]: "#f1f5f9",
456
+ ["--curator-assistant-bubble-text"]: "#0f172a",
457
+ ["--curator-header-bg"]: "transparent",
458
+ ["--curator-composer-bg"]: "transparent",
459
+ ["--curator-focus-ring"]: "rgba(15, 23, 42, 0.16)",
264
460
  ["--curator-error"]: "#b91c1c",
265
- ["--curator-radius"]: `${RADIUS_LG}px`,
461
+ ["--curator-radius"]: "14px",
266
462
  };
267
463
  const DARK_VARS = {
268
464
  ["--curator-bg"]: "#0b1220",
@@ -272,9 +468,17 @@ const DARK_VARS = {
272
468
  ["--curator-border"]: "rgba(226, 232, 240, 0.1)",
273
469
  ["--curator-accent"]: "#e2e8f0",
274
470
  ["--curator-accent-text"]: "#0b1220",
275
- ["--curator-user-bubble"]: "#1f2937",
471
+ // Dark mode keeps the same relationship: the user's bubble is the lighter,
472
+ // more saturated one; the assistant sits on a raised neutral surface.
473
+ ["--curator-user-bubble"]: "#e2e8f0",
474
+ ["--curator-user-bubble-text"]: "#0b1220",
475
+ ["--curator-assistant-bubble"]: "#1f2937",
476
+ ["--curator-assistant-bubble-text"]: "#e2e8f0",
477
+ ["--curator-header-bg"]: "transparent",
478
+ ["--curator-composer-bg"]: "transparent",
479
+ ["--curator-focus-ring"]: "rgba(226, 232, 240, 0.2)",
276
480
  ["--curator-error"]: "#fca5a5",
277
- ["--curator-radius"]: `${RADIUS_LG}px`,
481
+ ["--curator-radius"]: "14px",
278
482
  };
279
483
  const containerStyle = {
280
484
  display: "flex",
@@ -287,9 +491,9 @@ const containerStyle = {
287
491
  border: "1px solid var(--curator-border)",
288
492
  borderRadius: "var(--curator-radius)",
289
493
  overflow: "hidden",
290
- fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
494
+ fontFamily: "var(--curator-font-family)",
291
495
  fontSize: FONT_BASE,
292
- lineHeight: 1.5,
496
+ lineHeight: "var(--curator-line-height, 1.5)",
293
497
  boxSizing: "border-box",
294
498
  };
295
499
  const headerStyle = {
@@ -342,15 +546,39 @@ const viewportStyle = {
342
546
  // clips rather than growing a horizontal scrollbar across the whole thread.
343
547
  overflowX: "hidden",
344
548
  minWidth: 0,
345
- padding: `${SPACE_4}px ${SPACE_4}px ${SPACE_3}px`,
549
+ padding: `${SPACE_4} ${SPACE_4} ${SPACE_3}`,
346
550
  display: "flex",
347
551
  flexDirection: "column",
348
- gap: SPACE_2,
552
+ gap: SPACE_3,
349
553
  };
350
554
  const welcomeStyle = {
351
555
  color: "var(--curator-muted)",
352
- fontSize: 13,
353
- padding: `${SPACE_2}px 0`,
556
+ fontSize: FONT_SM,
557
+ padding: `${SPACE_2} 0`,
558
+ };
559
+ const promptsStyle = {
560
+ display: "flex",
561
+ flexWrap: "wrap",
562
+ gap: 6,
563
+ // Sits under the welcome line rather than beside it: the welcome says what
564
+ // this is, the prompts say what to do, and that reads as a sequence.
565
+ paddingTop: 2,
566
+ };
567
+ const promptStyle = {
568
+ // Outlined, not filled. A filled chip competes with the send button for
569
+ // "primary action", and there are several of these — a row of solid accent
570
+ // blocks in an otherwise empty panel is the loudest thing on the screen.
571
+ border: "1px solid var(--curator-border)",
572
+ borderRadius: RADIUS_SM,
573
+ background: "transparent",
574
+ color: "var(--curator-text)",
575
+ fontFamily: "inherit",
576
+ fontSize: FONT_SM,
577
+ lineHeight: 1.3,
578
+ padding: `6px ${SPACE_2}`,
579
+ cursor: "pointer",
580
+ textAlign: "left",
581
+ maxWidth: "100%",
354
582
  };
355
583
  const rowStartStyle = {
356
584
  // Column so the retry action bar can stack beneath the bubble, both
@@ -362,12 +590,18 @@ const rowStartStyle = {
362
590
  minWidth: 0,
363
591
  };
364
592
  const rowEndStyle = {
593
+ // Column, mirroring the assistant row. It was a plain row, which made the
594
+ // bubble and the timestamp siblings on one line — so a user message rendered
595
+ // as "hello7:19 AM", the stamp jammed against the bubble with no gap, while
596
+ // the assistant's sat neatly underneath. The two sides should differ in
597
+ // alignment, not in structure.
365
598
  display: "flex",
599
+ flexDirection: "column",
600
+ alignItems: "flex-end",
366
601
  width: "100%",
367
602
  // Same `min-width: auto` trap as the bubble: without this the row itself
368
603
  // refuses to shrink and the cap on the bubble inside it never binds.
369
604
  minWidth: 0,
370
- justifyContent: "flex-end",
371
605
  };
372
606
  const actionBarStyle = {
373
607
  display: "flex",
@@ -401,7 +635,7 @@ const bubbleBaseStyle = {
401
635
  // and the whole thread scrolls sideways. This is the fix for that; the
402
636
  // wrapping rules below only get a chance to apply once the box can shrink.
403
637
  minWidth: 0,
404
- padding: `${SPACE_2 + 2}px ${SPACE_3}px`,
638
+ padding: `calc(${SPACE_2} + 2px) ${SPACE_3}`,
405
639
  borderRadius: RADIUS_MD,
406
640
  whiteSpace: "pre-wrap",
407
641
  // `overflow-wrap: anywhere` is the standard property and it breaks inside an
@@ -429,7 +663,7 @@ const attachmentChipStyle = {
429
663
  // Tinted from the bubble's own text colour so the chip reads as part of the
430
664
  // message in either theme, without needing its own palette token.
431
665
  background: "color-mix(in srgb, currentColor 10%, transparent)",
432
- fontSize: 12,
666
+ fontSize: FONT_SM,
433
667
  lineHeight: 1.4,
434
668
  verticalAlign: "middle",
435
669
  };
@@ -444,12 +678,12 @@ const attachmentMetaStyle = {
444
678
  };
445
679
  const userBubbleStyle = {
446
680
  background: "var(--curator-user-bubble)",
447
- color: "var(--curator-text)",
681
+ color: "var(--curator-user-bubble-text, var(--curator-text))",
448
682
  borderBottomRightRadius: 4,
449
683
  };
450
684
  const assistantBubbleStyle = {
451
- background: "var(--curator-accent)",
452
- color: "var(--curator-accent-text)",
685
+ background: "var(--curator-assistant-bubble, var(--curator-accent))",
686
+ color: "var(--curator-assistant-bubble-text, var(--curator-accent-text))",
453
687
  borderBottomLeftRadius: 4,
454
688
  };
455
689
  const typingDotsStyle = {
@@ -470,7 +704,7 @@ const queuedRowStyle = {
470
704
  display: "flex",
471
705
  alignItems: "center",
472
706
  gap: SPACE_2,
473
- padding: `${SPACE_2}px ${SPACE_3}px 0`,
707
+ padding: `${SPACE_2} ${SPACE_3} 0`,
474
708
  fontSize: FONT_SM,
475
709
  color: "var(--curator-muted)",
476
710
  minWidth: 0,
@@ -503,15 +737,55 @@ const timestampStyle = {
503
737
  fontSize: FONT_XS,
504
738
  color: "var(--curator-muted)",
505
739
  opacity: 0.7,
506
- marginTop: 2,
740
+ marginTop: 4,
507
741
  padding: "0 2px",
508
742
  userSelect: "none",
509
743
  };
744
+ /** Pending attachments sit above the input, on their own row, like the queue row. */
745
+ const pendingRowStyle = {
746
+ display: "flex",
747
+ flexWrap: "wrap",
748
+ gap: 6,
749
+ paddingLeft: SPACE_3,
750
+ paddingRight: SPACE_3,
751
+ paddingBottom: 6,
752
+ };
753
+ /** Sized to match the send button so the composer's two ends balance. */
754
+ const attachButtonStyle = {
755
+ display: "inline-flex",
756
+ alignItems: "center",
757
+ justifyContent: "center",
758
+ flexShrink: 0,
759
+ width: 32,
760
+ height: 32,
761
+ marginBottom: 2,
762
+ border: "none",
763
+ borderRadius: RADIUS_SM,
764
+ background: "transparent",
765
+ color: "var(--curator-muted)",
766
+ cursor: "pointer",
767
+ padding: 0,
768
+ };
769
+ /** The chip's own dismiss control; smaller than the header's close button. */
770
+ const chipRemoveStyle = {
771
+ display: "inline-flex",
772
+ alignItems: "center",
773
+ justifyContent: "center",
774
+ width: 14,
775
+ height: 14,
776
+ marginLeft: 2,
777
+ padding: 0,
778
+ border: "none",
779
+ background: "transparent",
780
+ color: "inherit",
781
+ cursor: "pointer",
782
+ opacity: 0.7,
783
+ };
510
784
  const composerRootStyle = {
511
785
  display: "flex",
512
786
  gap: SPACE_2,
513
787
  alignItems: "flex-end",
514
- padding: `${SPACE_3}px ${SPACE_3}px ${SPACE_2}px`,
788
+ padding: `${SPACE_3} ${SPACE_3} ${SPACE_2}`,
515
789
  flexShrink: 0,
516
790
  };
517
791
  const textareaStyle = {
@@ -519,7 +793,7 @@ const textareaStyle = {
519
793
  resize: "none",
520
794
  border: "1px solid var(--curator-border)",
521
795
  borderRadius: RADIUS_SM,
522
- padding: `${SPACE_2}px ${SPACE_3}px`,
796
+ padding: `${SPACE_2} ${SPACE_3}`,
523
797
  background: "var(--curator-bg)",
524
798
  color: "var(--curator-text)",
525
799
  fontFamily: "inherit",
@@ -572,6 +846,10 @@ if (typeof document !== "undefined") {
572
846
  transform: none;
573
847
  }
574
848
  }
849
+ [data-curator-chat] [data-curator-part~="prompt"]:hover {
850
+ background-color: var(--curator-user-bubble);
851
+ border-color: var(--curator-muted);
852
+ }
575
853
  [data-curator-chat] button:disabled { opacity: 0.4; cursor: not-allowed; }
576
854
  [data-curator-chat] textarea {
577
855
  transition: border-color 120ms ease, box-shadow 120ms ease;