panelui-web 0.4.0 → 0.6.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.
@@ -0,0 +1,295 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * Message — one turn of a conversation with an assistant.
5
+ *
6
+ * ```tsx
7
+ * <Message from="user">
8
+ * <Message.Content>
9
+ * <Message.Bubble>
10
+ * <Message.BubbleContent>What changed in the Q3 numbers?</Message.BubbleContent>
11
+ * </Message.Bubble>
12
+ * </Message.Content>
13
+ * </Message>
14
+ *
15
+ * <Message from="assistant">
16
+ * <Message.Content>
17
+ * <Message.Bubble>
18
+ * <Message.BubbleContent>Revenue rose 12%…</Message.BubbleContent>
19
+ * </Message.Bubble>
20
+ * <Message.Actions>
21
+ * <Message.Action label="Copy" copy={text} />
22
+ * </Message.Actions>
23
+ * </Message.Content>
24
+ * </Message>
25
+ * ```
26
+ *
27
+ * ## Two kinds of turn, drawn differently
28
+ *
29
+ * What the person writes is short and is theirs, so it sits in a bubble on
30
+ * the trailing side, the way a sent message does. What the assistant writes is
31
+ * long, has paragraphs, lists and code, and is the thing being read, so it is
32
+ * not boxed at all: it runs the full width as prose, and the page is its
33
+ * surface. A bubble round a page of text only narrows it.
34
+ *
35
+ * ## Actions under the answer
36
+ *
37
+ * Copy, retry, rate and the switcher between versions of an answer sit in a
38
+ * row under it. On the latest answer they stay in view, because that is the
39
+ * one they are for. On earlier ones, `reveal="hover"` keeps them out of the
40
+ * way until the turn is hovered or focused, and on a touch screen, where
41
+ * there is no hover, they always show.
42
+ *
43
+ * ## While it is being written
44
+ *
45
+ * `streaming` puts a caret after the last word and marks the turn busy, so a
46
+ * screen reader waits for it to finish. Before the first word arrives,
47
+ * `Message.Status` says what the assistant is doing, as a line that shimmers.
48
+ */
49
+ import { Tooltip } from '@base-ui/react/tooltip';
50
+ import { createContext, useContext, useEffect, useRef, useState, } from 'react';
51
+ import { tv } from 'tailwind-variants';
52
+ const messageVariants = tv({
53
+ slots: {
54
+ root: 'group/message flex w-full gap-3',
55
+ group: 'flex w-full flex-col gap-1.5',
56
+ avatar: 'flex w-8 shrink-0 justify-center pt-0.5 empty:invisible',
57
+ content: 'flex min-w-0 flex-col gap-1.5',
58
+ header: 'flex items-baseline gap-2 text-xs text-muted-foreground [&_strong]:font-medium [&_strong]:text-foreground',
59
+ bubble: 'min-w-0',
60
+ bubbleContent: [
61
+ 'min-w-0 text-[0.9375rem] leading-7 text-foreground [overflow-wrap:anywhere]',
62
+ // Prose: what an answer is usually made of.
63
+ '[&_p]:my-3 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
64
+ '[&_ul]:my-3 [&_ul]:list-disc [&_ul]:ps-5 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:ps-5 [&_li]:my-1 [&_li]:ps-1 [&_li::marker]:text-muted-foreground',
65
+ // Links in the text; a component's own link inside it keeps its own look.
66
+ '[&_strong]:font-semibold [&_a:not([data-slot])]:underline [&_a:not([data-slot])]:underline-offset-4',
67
+ '[&_:not(pre)>code]:rounded-md [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-[0.8125rem]',
68
+ '[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:bg-muted/50 [&_pre]:p-3.5 [&_pre]:font-mono [&_pre]:text-[0.8125rem] [&_pre]:leading-6',
69
+ '[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:font-semibold',
70
+ ],
71
+ // The caret is a soft block that breathes rather than blinks. Written out
72
+ // twice: after the text itself, and inside its last block.
73
+ caret: "after:ms-0.5 after:inline-block after:h-[1.05em] after:w-[0.5em] after:translate-y-[0.18em] after:rounded-[3px] after:bg-foreground after:content-[''] after:animate-pulse motion-reduce:after:animate-none",
74
+ caretLast: "[&>:last-child]:after:ms-0.5 [&>:last-child]:after:inline-block [&>:last-child]:after:h-[1.05em] [&>:last-child]:after:w-[0.5em] [&>:last-child]:after:translate-y-[0.18em] [&>:last-child]:after:rounded-[3px] [&>:last-child]:after:bg-foreground [&>:last-child]:after:content-[''] [&>:last-child]:after:animate-pulse motion-reduce:[&>:last-child]:after:animate-none",
75
+ footer: 'flex items-center gap-2 text-xs text-muted-foreground',
76
+ actions: [
77
+ '-ms-1.5 flex items-center gap-0.5 text-muted-foreground',
78
+ 'transition-opacity duration-200 motion-reduce:transition-none',
79
+ ],
80
+ action: [
81
+ 'inline-flex size-8 cursor-pointer items-center justify-center rounded-lg outline-none',
82
+ 'transition-[background-color,color,scale] duration-150 hover:bg-muted hover:text-foreground active:scale-90',
83
+ 'focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none',
84
+ 'disabled:pointer-events-none disabled:opacity-40',
85
+ 'aria-pressed:text-foreground [&_svg]:size-4 [&_svg]:shrink-0',
86
+ ],
87
+ // Copied: the check is drawn in the success colour, then the icon comes back.
88
+ actionDone: 'text-[var(--success)] hover:text-[var(--success)]',
89
+ tooltip: [
90
+ 'z-50 rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md',
91
+ 'origin-(--transform-origin) transition-[opacity,scale] duration-150 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:opacity-0',
92
+ 'motion-reduce:transition-none',
93
+ ],
94
+ branch: 'flex items-center text-xs tabular-nums text-muted-foreground',
95
+ branchButton: [
96
+ 'inline-flex size-7 cursor-pointer items-center justify-center rounded-lg outline-none transition-colors',
97
+ 'hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
98
+ 'disabled:pointer-events-none disabled:opacity-35 [&_svg]:size-4',
99
+ ],
100
+ status: [
101
+ 'inline-flex w-fit items-center gap-2 text-[0.9375rem] leading-7',
102
+ // A highlight sweeping across muted text: working, without a spinner.
103
+ 'bg-[linear-gradient(90deg,var(--muted-foreground)_0%,var(--muted-foreground)_40%,var(--foreground)_50%,var(--muted-foreground)_60%,var(--muted-foreground)_100%)] bg-[length:250%_100%] bg-clip-text text-transparent',
104
+ 'animate-[message-shimmer_2.2s_linear_infinite] motion-reduce:animate-none motion-reduce:bg-none motion-reduce:text-muted-foreground',
105
+ ],
106
+ attachments: 'flex flex-wrap gap-2',
107
+ attachment: 'flex items-center gap-2.5 overflow-hidden rounded-xl border bg-background text-xs',
108
+ attachmentImage: 'size-20 object-cover',
109
+ attachmentFile: 'h-14 max-w-60 py-2 pe-3.5 ps-2',
110
+ attachmentGlyph: 'flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4',
111
+ attachmentName: 'block truncate font-medium text-foreground',
112
+ attachmentMeta: 'block truncate text-muted-foreground',
113
+ },
114
+ variants: {
115
+ from: {
116
+ user: {
117
+ root: 'flex-row-reverse',
118
+ content: 'max-w-[min(80%,36rem)] items-end',
119
+ header: 'justify-end',
120
+ // A squared corner on the sender's side, at the top, where the turn starts.
121
+ bubble: 'rounded-[1.25rem] rounded-se-md bg-muted px-4 py-2.5',
122
+ bubbleContent: 'leading-6 [&_p]:my-2',
123
+ footer: 'flex-row-reverse',
124
+ attachments: 'justify-end',
125
+ },
126
+ assistant: {
127
+ content: 'flex-1',
128
+ },
129
+ },
130
+ reveal: {
131
+ always: {},
132
+ // Earlier answers: out of the way until the turn is hovered or focused,
133
+ // and always there on a touch screen, which has no hover.
134
+ hover: {
135
+ actions: 'opacity-0 group-hover/message:opacity-100 group-focus-within/message:opacity-100 has-data-popup-open:opacity-100 pointer-coarse:opacity-100',
136
+ },
137
+ },
138
+ },
139
+ defaultVariants: { from: 'assistant', reveal: 'always' },
140
+ });
141
+ const MessageContext = createContext({ from: 'assistant', streaming: false });
142
+ const GroupContext = createContext(null);
143
+ /** One turn. Put `Message.Avatar` and `Message.Content` in it. */
144
+ function MessageRoot({ from: fromProp, streaming = false, className, ...props }) {
145
+ const group = useContext(GroupContext);
146
+ const from = fromProp ?? group?.from ?? 'assistant';
147
+ return (_jsx(MessageContext.Provider, { value: { from, streaming }, children: _jsx("div", { "data-slot": "message", "data-from": from, "data-streaming": streaming ? '' : undefined, "aria-busy": streaming || undefined, className: messageVariants({ from }).root({ className }), ...props }) }));
148
+ }
149
+ MessageRoot.displayName = 'Message';
150
+ /**
151
+ * Turns in a row from the same side, set closer together. Give the avatar to
152
+ * the first and an empty `Message.Avatar` to the rest, so they line up.
153
+ */
154
+ function MessageGroup({ from = 'assistant', className, ...props }) {
155
+ return (_jsx(GroupContext.Provider, { value: { from }, children: _jsx("div", { "data-slot": "message-group", className: messageVariants().group({ className }), ...props }) }));
156
+ }
157
+ MessageGroup.displayName = 'Message.Group';
158
+ /** Who it is from, beside the turn's first line. Leave it empty to keep the space. */
159
+ function MessageAvatar({ className, ...props }) {
160
+ return _jsx("div", { "data-slot": "message-avatar", className: messageVariants().avatar({ className }), ...props });
161
+ }
162
+ MessageAvatar.displayName = 'Message.Avatar';
163
+ /** The column with the header, the bubble, the attachments and the actions. */
164
+ function MessageContent({ className, ...props }) {
165
+ const { from } = useContext(MessageContext);
166
+ return _jsx("div", { "data-slot": "message-content", className: messageVariants({ from }).content({ className }), ...props });
167
+ }
168
+ MessageContent.displayName = 'Message.Content';
169
+ /** A line above the turn: a name in `<strong>`, a time, a model. */
170
+ function MessageHeader({ className, ...props }) {
171
+ const { from } = useContext(MessageContext);
172
+ return _jsx("div", { "data-slot": "message-header", className: messageVariants({ from }).header({ className }), ...props });
173
+ }
174
+ MessageHeader.displayName = 'Message.Header';
175
+ /** The surface of a person's turn. On the assistant's it draws nothing, and the text is the page's. */
176
+ function MessageBubble({ className, ...props }) {
177
+ const { from } = useContext(MessageContext);
178
+ return _jsx("div", { "data-slot": "message-bubble", className: messageVariants({ from }).bubble({ className }), ...props });
179
+ }
180
+ MessageBubble.displayName = 'Message.Bubble';
181
+ /**
182
+ * The text. Paragraphs, lists, inline code, code blocks and headings inside
183
+ * it are set as prose, so rendered Markdown can go straight in.
184
+ */
185
+ function MessageBubbleContent({ className, children, ...props }) {
186
+ const { from, streaming } = useContext(MessageContext);
187
+ const slots = messageVariants({ from });
188
+ // The caret follows the last word: after the text itself, or inside the last block.
189
+ const plain = typeof children === 'string' || typeof children === 'number';
190
+ const caret = streaming ? (plain ? slots.caret() : slots.caretLast()) : undefined;
191
+ return (_jsx("div", { "data-slot": "message-bubble-content", className: slots.bubbleContent({ className: [caret, className] }), ...props, children: children }));
192
+ }
193
+ MessageBubbleContent.displayName = 'Message.BubbleContent';
194
+ /** A line under the turn: sent, read, a model and how long it took. */
195
+ function MessageFooter({ className, ...props }) {
196
+ const { from } = useContext(MessageContext);
197
+ return _jsx("div", { "data-slot": "message-footer", className: messageVariants({ from }).footer({ className }), ...props });
198
+ }
199
+ MessageFooter.displayName = 'Message.Footer';
200
+ /** The row of actions under a turn. */
201
+ function MessageActions({ reveal, className, ...props }) {
202
+ return (_jsx(Tooltip.Provider, { delay: 400, closeDelay: 0, children: _jsx("div", { role: "toolbar", "aria-label": "Message actions", "data-slot": "message-actions", className: messageVariants({ reveal }).actions({ className }), ...props }) }));
203
+ }
204
+ MessageActions.displayName = 'Message.Actions';
205
+ /** An icon button with a tooltip: copy, retry, rate, share. */
206
+ function MessageAction({ label, children, copy, pressed, onClick, className, ...props }) {
207
+ const [copied, setCopied] = useState(false);
208
+ const timer = useRef(undefined);
209
+ useEffect(() => () => clearTimeout(timer.current), []);
210
+ const slots = messageVariants();
211
+ const press = (event) => {
212
+ onClick?.(event);
213
+ if (event.defaultPrevented || copy === undefined)
214
+ return;
215
+ const text = typeof copy === 'function' ? copy() : copy;
216
+ // A browser can refuse the clipboard by throwing or by rejecting; either way the check still shows.
217
+ try {
218
+ void navigator.clipboard?.writeText(text).catch(() => { });
219
+ }
220
+ catch { }
221
+ setCopied(true);
222
+ clearTimeout(timer.current);
223
+ timer.current = setTimeout(() => setCopied(false), 1600);
224
+ };
225
+ const shown = copied ? 'Copied' : label;
226
+ return (_jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { type: "button", "aria-label": shown, "aria-pressed": pressed, "data-slot": "message-action", className: slots.action({ className: [copied && slots.actionDone(), className] }), onClick: press, ...props, children: copied ? _jsx(CheckGlyph, {}) : (children ?? (copy !== undefined ? _jsx(CopyGlyph, {}) : null)) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Positioner, { side: "bottom", sideOffset: 6, className: "z-50", children: _jsx(Tooltip.Popup, { className: slots.tooltip(), children: shown }) }) })] }));
227
+ }
228
+ MessageAction.displayName = 'Message.Action';
229
+ /** "‹ 2 / 3 ›": steps between the versions of an answer that was retried or edited. */
230
+ function MessageBranch({ count, index, onIndexChange, className, ...props }) {
231
+ const slots = messageVariants();
232
+ return (_jsxs("div", { role: "group", "aria-label": "Versions", "data-slot": "message-branch", className: slots.branch({ className }), ...props, children: [_jsx("button", { type: "button", "aria-label": "Previous version", className: slots.branchButton(), disabled: index <= 0, onClick: () => onIndexChange(index - 1), children: _jsx(ChevronGlyph, { flip: true }) }), _jsxs("span", { "aria-live": "polite", className: "min-w-9 text-center", children: [index + 1, " / ", count] }), _jsx("button", { type: "button", "aria-label": "Next version", className: slots.branchButton(), disabled: index >= count - 1, onClick: () => onIndexChange(index + 1), children: _jsx(ChevronGlyph, {}) })] }));
233
+ }
234
+ MessageBranch.displayName = 'Message.Branch';
235
+ /**
236
+ * What the assistant is doing before its first word: "Thinking", "Reading 4
237
+ * files". Announced as it changes. Replace it with the text once that arrives.
238
+ */
239
+ function MessageStatus({ className, ...props }) {
240
+ return (_jsxs(_Fragment, { children: [_jsx("style", { href: "panelui-message-shimmer", precedence: "default", children: '@keyframes message-shimmer{from{background-position:100% 0}to{background-position:-150% 0}}' }), _jsx("div", { role: "status", "data-slot": "message-status", className: messageVariants().status({ className }), ...props })] }));
241
+ }
242
+ MessageStatus.displayName = 'Message.Status';
243
+ /** The files sent with a turn, above its bubble. */
244
+ function MessageAttachments({ className, ...props }) {
245
+ const { from } = useContext(MessageContext);
246
+ return (_jsx("div", { "data-slot": "message-attachments", className: messageVariants({ from }).attachments({ className }), ...props }));
247
+ }
248
+ MessageAttachments.displayName = 'Message.Attachments';
249
+ /** One sent file: a picture, or a card with a glyph, the name and its size. */
250
+ function MessageAttachment({ name, src, meta, icon, className, ...props }) {
251
+ const slots = messageVariants();
252
+ if (src) {
253
+ return (_jsx("div", { "data-slot": "message-attachment", className: slots.attachment({ className }), ...props, children: _jsx("img", { src: src, alt: name, className: slots.attachmentImage() }) }));
254
+ }
255
+ return (_jsxs("div", { "data-slot": "message-attachment", className: slots.attachment({ className: [slots.attachmentFile(), className] }), ...props, children: [_jsx("span", { className: slots.attachmentGlyph(), children: icon ?? _jsx(FileGlyph, {}) }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: slots.attachmentName(), children: name }), meta ? _jsx("span", { className: slots.attachmentMeta(), children: meta }) : null] })] }));
256
+ }
257
+ MessageAttachment.displayName = 'Message.Attachment';
258
+ /* -------------------------------------------------------------------------- */
259
+ /* Glyphs */
260
+ /* -------------------------------------------------------------------------- */
261
+ const STROKE = {
262
+ fill: 'none',
263
+ stroke: 'currentColor',
264
+ strokeWidth: 2,
265
+ strokeLinecap: 'round',
266
+ strokeLinejoin: 'round',
267
+ };
268
+ function CopyGlyph() {
269
+ return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", ...STROKE, children: [_jsx("rect", { x: "8", y: "8", width: "13", height: "13", rx: "3" }), _jsx("path", { d: "M16 8V6a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v7a3 3 0 0 0 3 3h2" })] }));
270
+ }
271
+ function CheckGlyph() {
272
+ return (_jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", ...STROKE, children: _jsx("path", { d: "M5 12.5l4.5 4.5L19 7.5" }) }));
273
+ }
274
+ function ChevronGlyph({ flip = false }) {
275
+ return (_jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", ...STROKE, className: flip ? '-scale-x-100 rtl:scale-x-100' : 'rtl:-scale-x-100', children: _jsx("path", { d: "M9 6l6 6-6 6" }) }));
276
+ }
277
+ function FileGlyph() {
278
+ return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", ...STROKE, children: [_jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }), _jsx("path", { d: "M14 3v5h5" })] }));
279
+ }
280
+ export const Message = Object.assign(MessageRoot, {
281
+ Group: MessageGroup,
282
+ Avatar: MessageAvatar,
283
+ Content: MessageContent,
284
+ Header: MessageHeader,
285
+ Bubble: MessageBubble,
286
+ BubbleContent: MessageBubbleContent,
287
+ Footer: MessageFooter,
288
+ Actions: MessageActions,
289
+ Action: MessageAction,
290
+ Branch: MessageBranch,
291
+ Status: MessageStatus,
292
+ Attachments: MessageAttachments,
293
+ Attachment: MessageAttachment,
294
+ });
295
+ export { MessageGroup, MessageAvatar, MessageContent, MessageHeader, MessageBubble, MessageBubbleContent, MessageFooter, MessageActions, MessageAction, MessageBranch, MessageStatus, MessageAttachments, MessageAttachment, };
@@ -47,7 +47,10 @@ import { tv } from 'tailwind-variants';
47
47
  import { LAND, LAND_COLUMNS, LAND_ROWS } from './land.js';
48
48
  const planetVariants = tv({
49
49
  slots: {
50
- root: 'flex w-full flex-col items-center gap-4 text-foreground',
50
+ // `isolate` keeps the markers' depth ordering inside the planet. Each marker
51
+ // sets its own z-index, and without a stacking context of its own those
52
+ // numbers compete with the page's sidebars and menus, and win.
53
+ root: 'isolate flex w-full flex-col items-center gap-4 text-foreground',
51
54
  // `touch-pan-y` leaves a vertical swipe to the page. A globe that traps
52
55
  // the scroll it sits in is worse than one that only turns sideways.
53
56
  globe: 'relative aspect-square w-full max-w-md cursor-grab touch-pan-y select-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background active:cursor-grabbing',