@hilum/ui 3.3.4 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
  3. package/dist/chunk-RI6QJR2L.js.map +1 -0
  4. package/dist/index.d.ts +800 -122
  5. package/dist/index.js +10458 -1119
  6. package/dist/index.js.map +1 -1
  7. package/dist/tokens.css +17 -0
  8. package/dist/tokens.d.ts +3 -0
  9. package/dist/tokens.js +1 -1
  10. package/llms.txt +18 -4
  11. package/package.json +19 -1
  12. package/src/components/accordion.tsx +746 -58
  13. package/src/components/alert-dialog.tsx +11 -5
  14. package/src/components/ask-user-questions.tsx +1366 -0
  15. package/src/components/badge.tsx +105 -24
  16. package/src/components/button.tsx +207 -38
  17. package/src/components/card.tsx +3 -2
  18. package/src/components/chat-message.tsx +116 -0
  19. package/src/components/checkbox-group.tsx +403 -0
  20. package/src/components/checkbox.tsx +6 -3
  21. package/src/components/color-input.tsx +3 -1
  22. package/src/components/color-picker.tsx +1992 -80
  23. package/src/components/dialog.tsx +141 -81
  24. package/src/components/dropdown-menu.tsx +21 -13
  25. package/src/components/dropdown.tsx +245 -0
  26. package/src/components/file-thumbnail.tsx +156 -0
  27. package/src/components/hover-card.tsx +2 -0
  28. package/src/components/input-copy.tsx +307 -0
  29. package/src/components/input-group.tsx +303 -108
  30. package/src/components/input-message.tsx +883 -0
  31. package/src/components/input-number.tsx +62 -21
  32. package/src/components/input.tsx +6 -3
  33. package/src/components/menu-item.tsx +156 -0
  34. package/src/components/mobile-drawer.tsx +50 -0
  35. package/src/components/nav-item.tsx +43 -0
  36. package/src/components/nav-menu.tsx +47 -0
  37. package/src/components/popover.tsx +3 -1
  38. package/src/components/radio-group.tsx +381 -35
  39. package/src/components/scroll-area.tsx +9 -2
  40. package/src/components/select.tsx +708 -121
  41. package/src/components/sheet.tsx +15 -6
  42. package/src/components/slider.tsx +1583 -20
  43. package/src/components/status-badge.tsx +2 -0
  44. package/src/components/switch.tsx +245 -28
  45. package/src/components/table.tsx +191 -77
  46. package/src/components/tabs-subtle.tsx +411 -0
  47. package/src/components/tabs.tsx +459 -49
  48. package/src/components/textarea.tsx +7 -3
  49. package/src/components/thinking-indicator.tsx +106 -0
  50. package/src/components/thinking-steps.tsx +382 -0
  51. package/src/components/tooltip.tsx +158 -33
  52. package/src/hooks/use-merge-split.tsx +359 -0
  53. package/src/hooks/use-proximity-hover.ts +208 -0
  54. package/src/index.ts +14 -0
  55. package/src/lib/elevated.tsx +52 -0
  56. package/src/lib/font-weight.ts +18 -0
  57. package/src/lib/icon-context.tsx +98 -0
  58. package/src/lib/icon-map.tsx +669 -0
  59. package/src/lib/interaction.ts +102 -0
  60. package/src/lib/scroll-fade.tsx +241 -0
  61. package/src/lib/shape-context.tsx +121 -0
  62. package/src/lib/springs.ts +30 -0
  63. package/src/lib/surface-classes.ts +43 -0
  64. package/src/lib/surface-context.tsx +17 -0
  65. package/src/tokens/tokens.ts +3 -0
  66. package/dist/chunk-MAHPAP65.js.map +0 -1
@@ -1,34 +1,115 @@
1
- import * as React from "react";
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
2
4
  import { cva, type VariantProps } from "class-variance-authority";
3
5
  import { cn } from "../lib/utils";
6
+ import { useShape } from "../lib/shape-context";
7
+
8
+ const badgeColors = {
9
+ gray: "#a3a3a3",
10
+ red: "#ef4444",
11
+ orange: "#f97316",
12
+ amber: "#f59e0b",
13
+ yellow: "#eab308",
14
+ lime: "#84cc16",
15
+ green: "#22c55e",
16
+ emerald: "#10b981",
17
+ teal: "#14b8a6",
18
+ cyan: "#06b6d4",
19
+ blue: "#3b82f6",
20
+ indigo: "#6366f1",
21
+ violet: "#8b5cf6",
22
+ purple: "#a855f7",
23
+ fuchsia: "#d946ef",
24
+ pink: "#ec4899",
25
+ rose: "#f43f5e",
26
+ } as const;
4
27
 
5
- const badgeVariants = cva(
6
- "inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 caption font-medium transition-colors",
7
- {
8
- variants: {
9
- variant: {
10
- default: "bg-foreground text-background",
11
- secondary: "bg-muted text-muted-foreground",
12
- outline: "border border-border text-muted-foreground bg-transparent",
13
- brand: "bg-brand-primary text-background",
14
- success: "bg-brand-secondary/20 text-muted-foreground border border-brand-secondary/50",
15
- warning: "bg-brand-secondary/70 text-muted-foreground border border-brand-secondary",
16
- destructive: "bg-destructive/10 text-destructive border border-destructive/20",
17
- },
28
+ type BadgeColor = keyof typeof badgeColors;
29
+
30
+ const badgeVariants = cva("inline-flex items-center font-medium whitespace-nowrap", {
31
+ variants: {
32
+ variant: {
33
+ default: "",
34
+ secondary: "",
35
+ outline: "border border-border text-foreground",
36
+ brand: "",
37
+ success: "",
38
+ warning: "",
39
+ destructive: "",
40
+ solid: "",
41
+ dot: "border border-border text-foreground",
18
42
  },
19
- defaultVariants: {
20
- variant: "default",
43
+ size: {
44
+ sm: "h-5 px-2 text-[11px] gap-1",
45
+ md: "h-6 px-2.5 text-[12px] gap-1.5",
46
+ lg: "h-7 px-3 text-[13px] gap-1.5",
21
47
  },
22
48
  },
23
- );
24
-
25
- interface BadgeProps extends React.ComponentProps<"span">, VariantProps<typeof badgeVariants> {}
49
+ defaultVariants: {
50
+ variant: "solid",
51
+ size: "md",
52
+ },
53
+ });
26
54
 
27
- function Badge({ className, variant, ...props }: BadgeProps) {
28
- return (
29
- <span data-slot="badge" className={cn(badgeVariants({ variant, className }))} {...props} />
30
- );
55
+ interface BadgeProps
56
+ extends Omit<HTMLAttributes<HTMLSpanElement>, "color">, VariantProps<typeof badgeVariants> {
57
+ color?: BadgeColor;
31
58
  }
59
+
60
+ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
61
+ ({ className, variant = "solid", size = "md", color, children, style, ...props }, ref) => {
62
+ const shape = useShape();
63
+ const resolvedColor =
64
+ color ??
65
+ (variant === "brand"
66
+ ? "blue"
67
+ : variant === "success"
68
+ ? "green"
69
+ : variant === "warning"
70
+ ? "amber"
71
+ : variant === "destructive"
72
+ ? "red"
73
+ : "gray");
74
+ const colorValue = badgeColors[resolvedColor];
75
+ const isSolid = variant !== "dot" && variant !== "outline";
76
+ const dotSize = size === "sm" ? 6 : size === "lg" ? 8 : 7;
77
+
78
+ const colorStyle = isSolid
79
+ ? resolvedColor === "gray"
80
+ ? { backgroundColor: "var(--accent)", color: "var(--foreground)" }
81
+ : {
82
+ color: "var(--foreground)",
83
+ backgroundColor: `color-mix(in srgb, ${colorValue} 15%, var(--background))`,
84
+ }
85
+ : {};
86
+
87
+ const dotColor = resolvedColor === "gray" ? "var(--muted-foreground)" : colorValue;
88
+
89
+ return (
90
+ <span
91
+ ref={ref}
92
+ className={cn(badgeVariants({ variant, size }), shape.item, className)}
93
+ style={{ ...colorStyle, ...style }}
94
+ {...props}
95
+ >
96
+ {!isSolid && (
97
+ <span
98
+ className="shrink-0 rounded-full"
99
+ style={{
100
+ width: dotSize,
101
+ height: dotSize,
102
+ backgroundColor: dotColor,
103
+ }}
104
+ />
105
+ )}
106
+ {children}
107
+ </span>
108
+ );
109
+ },
110
+ );
111
+
32
112
  Badge.displayName = "Badge";
33
113
 
34
- export { Badge, badgeVariants };
114
+ export { Badge, badgeVariants, badgeColors };
115
+ export type { BadgeProps, BadgeColor };
@@ -1,63 +1,232 @@
1
- import * as React from "react";
1
+ "use client";
2
+
3
+ import {
4
+ cloneElement,
5
+ forwardRef,
6
+ isValidElement,
7
+ type ButtonHTMLAttributes,
8
+ type CSSProperties,
9
+ type ReactElement,
10
+ } from "react";
11
+ import { Slot } from "@radix-ui/react-slot";
2
12
  import { cva, type VariantProps } from "class-variance-authority";
3
- import { Slot } from "radix-ui";
13
+ import type { IconComponent } from "../lib/icon-context";
4
14
  import { cn } from "../lib/utils";
15
+ import { useShape } from "../lib/shape-context";
5
16
 
6
17
  const buttonVariants = cva(
7
18
  [
8
- "inline-flex shrink-0 items-center justify-center gap-2",
9
- "body font-medium whitespace-nowrap",
10
- "transition-[background-color,border-color,box-shadow,color,opacity,scale] duration-150 outline-none active:scale-[0.96]",
11
- "focus-visible:ring-2 focus-visible:ring-brand-primary/30 focus-visible:ring-offset-1",
12
- "disabled:pointer-events-none disabled:opacity-50",
13
- "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
19
+ "group relative isolate inline-flex items-center justify-center outline-none cursor-pointer",
20
+ "text-box-trim-both text-box-edge-cap-alphabetic",
21
+ "transition-colors duration-80",
22
+ "disabled:opacity-50 disabled:pointer-events-none",
23
+ "focus-visible:ring-1 focus-visible:ring-[#6B97FF]",
14
24
  ],
15
25
  {
16
26
  variants: {
17
27
  variant: {
18
- default:
19
- "bg-brand-primary text-background hover:bg-brand-primary/90 active:bg-brand-primary/80",
20
- destructive:
21
- "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80",
22
- outline: "bg-card text-muted-foreground shadow-natural hover:bg-muted rounded-xl",
23
- secondary: "bg-muted text-muted-foreground hover:bg-muted",
24
- brand:
25
- "bg-brand-secondary text-foreground hover:bg-brand-secondary/90 active:bg-brand-secondary/80",
26
- ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
28
+ default: "text-background",
29
+ primary: "text-background",
30
+ brand: "text-background",
31
+ secondary: "text-foreground",
32
+ outline: "border border-border text-foreground",
33
+ tertiary: "border border-border text-foreground",
34
+ destructive: "text-destructive border border-destructive/30",
35
+ ghost: "text-muted-foreground hover:text-foreground",
27
36
  link: "text-foreground underline-offset-4 hover:underline",
28
37
  },
29
38
  size: {
30
- default: "h-10 px-4 py-2 rounded-md",
31
- xs: "h-6 gap-1 px-2 caption rounded-md",
32
- sm: "h-8 gap-1.5 px-3 rounded-md",
33
- lg: "h-11 px-6 rounded-md",
34
- icon: "size-9 rounded-md",
35
- "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
36
- "icon-sm": "size-8 rounded-md",
37
- "icon-lg": "size-11 rounded-md",
39
+ xs: "h-6 px-2.5 text-[11px] gap-1",
40
+ sm: "h-7 px-3 text-[12px] gap-1",
41
+ default: "h-8 px-4 text-[13px] gap-1.5",
42
+ md: "h-8 px-4 text-[13px] gap-1.5",
43
+ lg: "h-9 px-5 text-[14px] gap-1.5",
44
+ "icon-xs": "h-7 w-7 p-0 [&_svg]:h-3 [&_svg]:w-3",
45
+ "icon-sm": "h-8 w-8 p-0 [&_svg]:h-3.5 [&_svg]:w-3.5",
46
+ icon: "h-9 w-9 p-0 [&_svg]:h-4 [&_svg]:w-4",
47
+ "icon-lg": "h-10 w-10 p-0 [&_svg]:h-5 [&_svg]:w-5",
38
48
  },
49
+ iconLeft: { true: "" },
50
+ iconRight: { true: "" },
39
51
  },
52
+ compoundVariants: [
53
+ { size: "sm", iconLeft: true, className: "pl-[6px]" },
54
+ { size: "md", iconLeft: true, className: "pl-[10px]" },
55
+ { size: "lg", iconLeft: true, className: "pl-[14px]" },
56
+ { size: "sm", iconRight: true, className: "pr-[6px]" },
57
+ { size: "md", iconRight: true, className: "pr-[10px]" },
58
+ { size: "lg", iconRight: true, className: "pr-[14px]" },
59
+ ],
40
60
  defaultVariants: {
41
- variant: "default",
42
- size: "default",
61
+ variant: "primary",
62
+ size: "md",
43
63
  },
44
64
  },
45
65
  );
46
66
 
47
- interface ButtonProps extends React.ComponentProps<"button">, VariantProps<typeof buttonVariants> {
67
+ interface ButtonProps
68
+ extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
48
69
  asChild?: boolean;
70
+ loading?: boolean;
71
+ leadingIcon?: IconComponent;
72
+ trailingIcon?: IconComponent;
73
+ /** Force the visual pressed/held state. Useful when the button drives an
74
+ * external open piece of UI (a popover, dropdown, etc.) so it reads as
75
+ * engaged while the menu is showing. */
76
+ active?: boolean;
49
77
  }
50
78
 
51
- function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
52
- const Comp = asChild ? Slot.Root : "button";
53
- return (
54
- <Comp
55
- data-slot="button"
56
- className={cn(buttonVariants({ variant, size, className }))}
57
- {...props}
58
- />
59
- );
60
- }
79
+ const bgVariants: Record<string, string> = {
80
+ default: "bg-foreground group-hover:bg-foreground/90 group-active:bg-foreground/80",
81
+ primary: "bg-foreground group-hover:bg-foreground/90 group-active:bg-foreground/80",
82
+ brand: "bg-brand group-hover:bg-brand/90 group-active:bg-brand/80",
83
+ secondary: "bg-accent group-hover:bg-accent/80 group-active:bg-accent",
84
+ outline: "bg-transparent group-hover:bg-hover group-active:bg-active",
85
+ tertiary: "bg-transparent group-hover:bg-hover group-active:bg-active",
86
+ destructive: "bg-destructive/10 group-hover:bg-destructive/15 group-active:bg-destructive/20",
87
+ ghost: "bg-transparent group-hover:bg-hover group-active:bg-active",
88
+ link: "bg-transparent",
89
+ };
90
+
91
+ const activeBgVariants: Record<string, string> = {
92
+ default: "bg-foreground/80",
93
+ primary: "bg-foreground/80",
94
+ brand: "bg-brand/80",
95
+ secondary: "bg-accent",
96
+ outline: "bg-active",
97
+ tertiary: "bg-active",
98
+ destructive: "bg-destructive/20",
99
+ ghost: "bg-active",
100
+ link: "bg-transparent",
101
+ };
102
+
103
+ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
104
+ (
105
+ {
106
+ className,
107
+ variant,
108
+ size,
109
+ asChild = false,
110
+ loading = false,
111
+ leadingIcon: LeadingIcon,
112
+ trailingIcon: TrailingIcon,
113
+ active = false,
114
+ disabled,
115
+ children,
116
+ style,
117
+ ...props
118
+ },
119
+ ref,
120
+ ) => {
121
+ const Comp = asChild ? Slot : "button";
122
+ const isIconOnly =
123
+ size === "icon" || size === "icon-xs" || size === "icon-sm" || size === "icon-lg";
124
+ const iconSize = size === "xs" || size === "sm" ? 14 : size === "lg" ? 20 : 16;
125
+ const shape = useShape();
126
+ const bgClass = active
127
+ ? activeBgVariants[variant ?? "primary"]
128
+ : bgVariants[variant ?? "primary"];
129
+
130
+ if (asChild && isValidElement(children)) {
131
+ const child = children as ReactElement<{ className?: string; style?: CSSProperties }>;
132
+ return (cloneElement as any)(child, {
133
+ ...props,
134
+ "data-slot": "button",
135
+ className: cn(
136
+ buttonVariants({
137
+ variant,
138
+ size,
139
+ iconLeft: !isIconOnly && !!LeadingIcon,
140
+ iconRight: !isIconOnly && !!TrailingIcon,
141
+ }),
142
+ shape.button,
143
+ child.props.className,
144
+ className,
145
+ ),
146
+ style: { ...child.props.style, ...style },
147
+ });
148
+ }
149
+
150
+ return (
151
+ <Comp
152
+ ref={ref}
153
+ data-slot="button"
154
+ className={cn(
155
+ buttonVariants({
156
+ variant,
157
+ size,
158
+ iconLeft: !isIconOnly && !!LeadingIcon,
159
+ iconRight: !isIconOnly && !!TrailingIcon,
160
+ }),
161
+ shape.button,
162
+ className,
163
+ )}
164
+ disabled={disabled || loading}
165
+ style={style}
166
+ {...props}
167
+ >
168
+ <span
169
+ aria-hidden
170
+ className={cn(
171
+ "absolute inset-0 rounded-[inherit] transition-[background-color,transform] duration-80 group-active:scale-[0.98]",
172
+ bgClass,
173
+ )}
174
+ />
175
+ <span className="relative inline-flex items-center justify-center gap-[inherit]">
176
+ {loading ? (
177
+ <>
178
+ <span className="flex items-center justify-center gap-[inherit] opacity-0">
179
+ {LeadingIcon && !isIconOnly && <LeadingIcon size={iconSize} strokeWidth={2} />}
180
+ {children}
181
+ {TrailingIcon && !isIconOnly && <TrailingIcon size={iconSize} strokeWidth={2} />}
182
+ </span>
183
+ <span className="absolute inset-0 flex items-center justify-center">
184
+ <svg className="h-8 w-8" viewBox="0 0 24 24" fill="none">
185
+ <path
186
+ d="M 12 12 C 14 8.5 19 8.5 19 12 C 19 15.5 14 15.5 12 12 C 10 8.5 5 8.5 5 12 C 5 15.5 10 15.5 12 12 Z"
187
+ stroke="currentColor"
188
+ strokeWidth="1.125"
189
+ strokeLinecap="round"
190
+ pathLength="100"
191
+ style={{
192
+ strokeDasharray: "15 85",
193
+ animation:
194
+ "spinner-move 2s linear infinite, spinner-dash 4s ease-in-out infinite",
195
+ }}
196
+ />
197
+ </svg>
198
+ </span>
199
+ </>
200
+ ) : isIconOnly ? (
201
+ <span className="[&_svg]:stroke-[1.5] [&_svg]:transition-[stroke-width] [&_svg]:duration-80 group-hover:[&_svg]:stroke-[2]">
202
+ {children}
203
+ </span>
204
+ ) : (
205
+ <>
206
+ {LeadingIcon && (
207
+ <LeadingIcon
208
+ size={iconSize}
209
+ strokeWidth={1.5}
210
+ className="transition-[stroke-width] duration-80 group-hover:stroke-[2]"
211
+ />
212
+ )}
213
+ <span>{children}</span>
214
+ {TrailingIcon && (
215
+ <TrailingIcon
216
+ size={iconSize}
217
+ strokeWidth={1.5}
218
+ className="transition-[stroke-width] duration-80 group-hover:stroke-[2]"
219
+ />
220
+ )}
221
+ </>
222
+ )}
223
+ </span>
224
+ </Comp>
225
+ );
226
+ },
227
+ );
228
+
61
229
  Button.displayName = "Button";
62
230
 
63
231
  export { Button, buttonVariants };
232
+ export type { ButtonProps };
@@ -1,16 +1,17 @@
1
1
  import * as React from "react";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { cn } from "../lib/utils";
4
+ import { motionClasses } from "../lib/interaction";
4
5
 
5
6
  /* ------------------------------------------------------------------ */
6
7
  /* Card root */
7
8
  /* ------------------------------------------------------------------ */
8
9
 
9
- const cardVariants = cva("rounded-xl", {
10
+ const cardVariants = cva(["rounded-xl", motionClasses], {
10
11
  variants: {
11
12
  variant: {
12
13
  default: "bg-card shadow-natural",
13
- muted: "bg-muted",
14
+ muted: "bg-muted shadow-natural",
14
15
  ghost: "bg-transparent",
15
16
  },
16
17
  },
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type ReactNode } from "react";
4
+ import { motion, type HTMLMotionProps } from "framer-motion";
5
+ import { cn } from "../lib/utils";
6
+ import { spring } from "../lib/springs";
7
+ import { useShape } from "../lib/shape-context";
8
+ import { FileThumbnail } from "./file-thumbnail";
9
+
10
+ interface ChatMessageProps extends Omit<HTMLMotionProps<"div">, "children"> {
11
+ /** Who sent the message. Drives alignment and bubble colour:
12
+ * `user` → right-aligned accent bubble, `assistant` → left-aligned plain text. */
13
+ from: "user" | "assistant";
14
+ /** Optional attachments rendered as square thumbnails above the bubble. */
15
+ files?: File[];
16
+ /** Side length of each attachment thumbnail in pixels. Defaults to 64. */
17
+ thumbnailSize?: number;
18
+ /** Timestamp shown in the hover-revealed meta row, before the actions.
19
+ * User-message only — ignored on assistant replies. Caller pre-formats it
20
+ * (e.g. `"Wednesday 6:08 PM"`). */
21
+ time?: ReactNode;
22
+ /** Icon-only action buttons shown in the hover-revealed meta row (e.g. copy,
23
+ * edit, regenerate). Rendered next to the timestamp. */
24
+ actions?: ReactNode;
25
+ /** Message body. When omitted the text bubble is dropped (attachment-only message). */
26
+ children?: ReactNode;
27
+ }
28
+
29
+ // ─── ChatMessage ──────────────────────────────────────────────────────────
30
+ // A single transcript entry with baked-in entrance + layout motion. Pairs with
31
+ // InputMessage's onSend: render one per sent/received message. `layout="position"`
32
+ // lets earlier messages slide up smoothly when a new one is appended.
33
+ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
34
+ ({ from, files, thumbnailSize = 64, time, actions, children, className, ...props }, ref) => {
35
+ const shape = useShape();
36
+ const isUser = from === "user";
37
+ // Timestamps are a user-message affordance; assistant replies show actions only.
38
+ const showTime = isUser && time != null;
39
+
40
+ return (
41
+ <motion.div
42
+ ref={ref}
43
+ layout="position"
44
+ initial={{ opacity: 0, y: 8, scale: 0.96 }}
45
+ animate={{ opacity: 1, y: 0, scale: 1 }}
46
+ transition={spring.moderate}
47
+ style={{ transformOrigin: isUser ? "bottom right" : "bottom left" }}
48
+ className={cn(
49
+ "group flex max-w-[80%] flex-col gap-1.5",
50
+ isUser ? "items-end self-end" : "items-start self-start",
51
+ className,
52
+ )}
53
+ {...props}
54
+ >
55
+ {files && files.length > 0 && (
56
+ <div className={cn("flex flex-wrap gap-1.5", isUser ? "justify-end" : "justify-start")}>
57
+ {files.map((file, i) => (
58
+ <FileThumbnail
59
+ key={`${file.name}-${file.size}-${file.lastModified}-${i}`}
60
+ file={file}
61
+ size={thumbnailSize}
62
+ />
63
+ ))}
64
+ </div>
65
+ )}
66
+ {children != null && children !== "" && (
67
+ <div
68
+ className={cn(
69
+ "py-2 text-[14px] whitespace-pre-wrap break-words",
70
+ // User keeps the bubble chrome (rounded fill + horizontal padding);
71
+ // the assistant reply is flush-left plain text with no background.
72
+ isUser
73
+ ? cn(
74
+ shape.bg,
75
+ // `text-pretty` is reserved for settled user bubbles. On the
76
+ // assistant reply it's left off on purpose: `text-wrap: pretty`
77
+ // re-balances the last lines on every content change, so a
78
+ // word-by-word stream visibly reflows earlier words to new
79
+ // lines. Default (normal) wrapping appends left-to-right and
80
+ // stays put as the text grows.
81
+ "px-3.5 text-pretty bg-[color-mix(in_oklab,var(--accent),var(--background)_45%)] text-accent-foreground",
82
+ )
83
+ : "text-foreground",
84
+ )}
85
+ >
86
+ {children}
87
+ </div>
88
+ )}
89
+ {(showTime || actions != null) && (
90
+ // Meta row: timestamp + icon-only actions. Always rendered (so it
91
+ // reserves its height and the gap between bubbles never shifts) but
92
+ // hidden until the message is hovered or an action is focused.
93
+ // The timestamp is a user-message affordance only — assistant replies
94
+ // show their actions alone. User rows read date → icons left-to-right.
95
+ <div
96
+ className={cn(
97
+ "flex items-center gap-2 px-1 text-[12px] leading-none text-muted-foreground select-none",
98
+ "opacity-0 pointer-events-none transition-opacity duration-150",
99
+ "group-hover:opacity-100 group-hover:pointer-events-auto",
100
+ "group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
101
+ )}
102
+ >
103
+ {showTime && <span className="tabular-nums">{time}</span>}
104
+ {actions != null && <span className="flex items-center gap-0.5">{actions}</span>}
105
+ </div>
106
+ )}
107
+ </motion.div>
108
+ );
109
+ },
110
+ );
111
+
112
+ ChatMessage.displayName = "ChatMessage";
113
+
114
+ export { ChatMessage };
115
+ export type { ChatMessageProps };
116
+ export default ChatMessage;