@handled-ai/design-system 0.18.58 → 0.19.0-rc.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.
Files changed (64) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/case-panel-activity-timeline.d.ts +2 -0
  4. package/dist/components/case-panel-activity-timeline.js +22 -1
  5. package/dist/components/case-panel-activity-timeline.js.map +1 -1
  6. package/dist/components/comment-composer.d.ts +29 -0
  7. package/dist/components/comment-composer.js +102 -0
  8. package/dist/components/comment-composer.js.map +1 -0
  9. package/dist/components/conversation-panel.d.ts +95 -0
  10. package/dist/components/conversation-panel.js +636 -0
  11. package/dist/components/conversation-panel.js.map +1 -0
  12. package/dist/components/detail-view.js +1 -1
  13. package/dist/components/detail-view.js.map +1 -1
  14. package/dist/components/owner-chips.d.ts +59 -0
  15. package/dist/components/owner-chips.js +256 -0
  16. package/dist/components/owner-chips.js.map +1 -0
  17. package/dist/components/pill.d.ts +1 -1
  18. package/dist/components/score-why-chips.d.ts +1 -1
  19. package/dist/components/signal-priority-popover.d.ts +1 -1
  20. package/dist/components/signal-priority-popover.js +16 -7
  21. package/dist/components/signal-priority-popover.js.map +1 -1
  22. package/dist/components/tabs.d.ts +1 -1
  23. package/dist/components/timeline-activity.d.ts +7 -0
  24. package/dist/components/timeline-activity.js +22 -1
  25. package/dist/components/timeline-activity.js.map +1 -1
  26. package/dist/components/virtualized-data-table.js +4 -4
  27. package/dist/components/virtualized-data-table.js.map +1 -1
  28. package/dist/index.d.ts +4 -1
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/safe-html.d.ts +11 -0
  32. package/dist/internal/safe-html.js +222 -0
  33. package/dist/internal/safe-html.js.map +1 -0
  34. package/dist/prototype/index.d.ts +1 -1
  35. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  36. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  37. package/dist/prototype/prototype-config.d.ts +1 -1
  38. package/dist/prototype/prototype-inbox-view.d.ts +1 -1
  39. package/dist/prototype/prototype-inbox-view.js +2 -0
  40. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  41. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  42. package/dist/prototype/prototype-shell.d.ts +1 -1
  43. package/dist/{signal-priority-popover-QJngMAj7.d.ts → signal-priority-popover-CZitE9xq.d.ts} +11 -2
  44. package/package.json +1 -1
  45. package/src/components/__tests__/comment-composer.test.tsx +57 -0
  46. package/src/components/__tests__/conversation-panel.test.tsx +157 -0
  47. package/src/components/__tests__/owner-chips.test.tsx +100 -0
  48. package/src/components/__tests__/signal-priority-popover.test.tsx +41 -4
  49. package/src/components/__tests__/timeline-activity.test.tsx +55 -0
  50. package/src/components/__tests__/virtualized-data-table-resize.test.tsx +18 -0
  51. package/src/components/case-panel-activity-timeline.tsx +20 -0
  52. package/src/components/comment-composer.tsx +119 -0
  53. package/src/components/conversation-panel.tsx +790 -0
  54. package/src/components/detail-view.tsx +3 -1
  55. package/src/components/owner-chips.tsx +335 -0
  56. package/src/components/signal-priority-popover.tsx +19 -6
  57. package/src/components/timeline-activity.tsx +37 -3
  58. package/src/components/virtualized-data-table.tsx +4 -4
  59. package/src/index.ts +4 -1
  60. package/src/internal/__tests__/safe-html.test.ts +53 -0
  61. package/src/internal/safe-html.ts +284 -0
  62. package/src/prototype/__tests__/detail-view-score-why.test.tsx +34 -0
  63. package/src/prototype/prototype-config.ts +5 -1
  64. package/src/prototype/prototype-inbox-view.tsx +2 -0
@@ -168,7 +168,9 @@ export function DetailViewThread({
168
168
  <span className="text-sm text-muted-foreground">{actionCount} actions</span>
169
169
  )}
170
170
  </div>
171
- <div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm">
171
+ {/* Minimal case-panel shadow (WIT-854): kept subtle but present, ~45%
172
+ lighter than shadow-sm. */}
173
+ <div className="rounded-xl border border-border bg-card overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.03)]">
172
174
  {children}
173
175
  </div>
174
176
  </div>
@@ -0,0 +1,335 @@
1
+ "use client"
2
+
3
+ /**
4
+ * owner-chips.tsx — disambiguates the two ownership concepts operators kept
5
+ * confusing on the case panel:
6
+ *
7
+ * 1. SIGNAL OWNER — Handled's OWN assignment. Who owns working this
8
+ * signal/action inside Handled. Editable here (assign / reassign /
9
+ * unassign). Replaces the ambiguous bare "Unassigned" chip.
10
+ *
11
+ * 2. ACCOUNT OWNER(S) — read-through from Salesforce. An account can carry
12
+ * more than one (AE + RM). Informational + links out to Salesforce; never
13
+ * assigned from inside Handled. Leads with the Salesforce mark.
14
+ *
15
+ * Single account owner -> a static, non-interactive chip (no dropdown).
16
+ * Multiple account owners -> stacked avatars + a ×N badge and a menu that
17
+ * lists each owner with a link out. This keeps a single owner cheap and quiet
18
+ * while still surfacing the full set when there is more than one.
19
+ *
20
+ * Presentational only: data + handlers come from the consumer (the app).
21
+ */
22
+
23
+ import * as React from "react"
24
+ import {
25
+ ChevronDown,
26
+ ChevronUp,
27
+ Check,
28
+ UserPlus,
29
+ UserMinus,
30
+ Info,
31
+ ArrowUpRight,
32
+ } from "lucide-react"
33
+
34
+ import { cn } from "../lib/utils"
35
+ import { getInitials } from "../lib/user-display"
36
+ import { BRAND_ICONS } from "../lib/icons"
37
+ import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
38
+ import {
39
+ DropdownMenu,
40
+ DropdownMenuTrigger,
41
+ DropdownMenuContent,
42
+ DropdownMenuItem,
43
+ DropdownMenuLabel,
44
+ DropdownMenuSeparator,
45
+ } from "./dropdown-menu"
46
+
47
+ export interface OwnerPerson {
48
+ /** Stable id (profile id for signal owner; SF user id for account owner). */
49
+ id?: string
50
+ name: string
51
+ email?: string
52
+ /** e.g. "Relationship Manager", "Account Executive". */
53
+ role?: string
54
+ /** Avatar image; falls back to initials when absent. */
55
+ avatarUrl?: string | null
56
+ /** External link (Salesforce) for an account owner. */
57
+ href?: string
58
+ }
59
+
60
+ /* ── shared bits ─────────────────────────────────────────────────────────── */
61
+
62
+ function SalesforceMark({ size = 14 }: { size?: number }) {
63
+ return (
64
+ // eslint-disable-next-line @next/next/no-img-element
65
+ <img
66
+ src={BRAND_ICONS.salesforce}
67
+ alt="Salesforce"
68
+ width={size}
69
+ height={size}
70
+ style={{ width: size, height: size, objectFit: "contain", display: "block" }}
71
+ />
72
+ )
73
+ }
74
+
75
+ function OwnerAvatar({ person, size = "sm" }: { person: OwnerPerson; size?: "sm" | "default" }) {
76
+ return (
77
+ <Avatar size={size} className="ring-background ring-2">
78
+ {person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={person.name} /> : null}
79
+ <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
80
+ {getInitials({ name: person.name, email: person.email })}
81
+ </AvatarFallback>
82
+ </Avatar>
83
+ )
84
+ }
85
+
86
+ const chipBase =
87
+ "inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] " +
88
+ "shadow-[0_1px_1px_rgba(0,0,0,0.03)]"
89
+
90
+ /* ── Signal owner (Handled assignment, editable) ─────────────────────────── */
91
+
92
+ export interface SignalOwnerChipProps {
93
+ /** Current Handled assignee, or null when unassigned. */
94
+ owner: OwnerPerson | null
95
+ /** Operators the case can be assigned to (preloaded — no fetch on open). */
96
+ assignableOwners?: OwnerPerson[]
97
+ onAssign?: (owner: OwnerPerson) => void
98
+ onUnassign?: () => void
99
+ /** Read-only: render a static chip without the assignment menu. */
100
+ disabled?: boolean
101
+ className?: string
102
+ }
103
+
104
+ function SignalOwnerChip({
105
+ owner,
106
+ assignableOwners = [],
107
+ onAssign,
108
+ onUnassign,
109
+ disabled,
110
+ className,
111
+ }: SignalOwnerChipProps) {
112
+ const [open, setOpen] = React.useState(false)
113
+
114
+ const value = (
115
+ <>
116
+ {owner ? (
117
+ <OwnerAvatar person={owner} />
118
+ ) : (
119
+ <span className="text-muted-foreground inline-flex size-[18px] items-center justify-center">
120
+ <UserPlus size={13} />
121
+ </span>
122
+ )}
123
+ <span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
124
+ Signal owner
125
+ </span>
126
+ <span className="bg-border/70 mx-0.5 h-3.5 w-px" aria-hidden />
127
+ <span className={cn("font-medium", owner ? "text-foreground" : "text-muted-foreground")}>
128
+ {owner ? owner.name.split(" ")[0] : "Unassigned"}
129
+ </span>
130
+ </>
131
+ )
132
+
133
+ // Read-only or nothing to assign to: static chip.
134
+ if (disabled || (!onAssign && !onUnassign)) {
135
+ return (
136
+ <span
137
+ data-slot="signal-owner-chip"
138
+ data-empty={owner ? undefined : "true"}
139
+ className={cn(chipBase, className)}
140
+ title="Who owns this signal inside Handled"
141
+ >
142
+ {value}
143
+ </span>
144
+ )
145
+ }
146
+
147
+ return (
148
+ <DropdownMenu open={open} onOpenChange={setOpen}>
149
+ <DropdownMenuTrigger asChild>
150
+ <button
151
+ type="button"
152
+ data-slot="signal-owner-chip"
153
+ data-empty={owner ? undefined : "true"}
154
+ className={cn(chipBase, "hover:bg-muted cursor-pointer transition-colors", className)}
155
+ title="Who owns this signal inside Handled"
156
+ >
157
+ {value}
158
+ <span className="text-muted-foreground ml-0.5">
159
+ {open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
160
+ </span>
161
+ </button>
162
+ </DropdownMenuTrigger>
163
+ <DropdownMenuContent align="start" className="w-64">
164
+ <DropdownMenuLabel className="flex flex-col gap-0.5">
165
+ <span className="text-[13px] font-semibold">Assign signal owner</span>
166
+ <span className="text-muted-foreground text-[11px] font-normal">
167
+ Who works this inside Handled, separate from the Salesforce account owner.
168
+ </span>
169
+ </DropdownMenuLabel>
170
+ <DropdownMenuSeparator />
171
+ {assignableOwners.map((o) => {
172
+ const active = !!owner && (owner.id ? owner.id === o.id : owner.name === o.name)
173
+ return (
174
+ <DropdownMenuItem
175
+ key={o.id ?? o.name}
176
+ onSelect={() => onAssign?.(o)}
177
+ className="gap-2"
178
+ >
179
+ <OwnerAvatar person={o} size="default" />
180
+ <span className="flex min-w-0 flex-col">
181
+ <span className="truncate text-[13px] font-medium">{o.name}</span>
182
+ {o.role ? (
183
+ <span className="text-muted-foreground truncate text-[11px]">{o.role}</span>
184
+ ) : null}
185
+ </span>
186
+ {active ? <Check size={14} className="text-foreground ml-auto" /> : null}
187
+ </DropdownMenuItem>
188
+ )
189
+ })}
190
+ {owner && onUnassign ? (
191
+ <>
192
+ <DropdownMenuSeparator />
193
+ <DropdownMenuItem onSelect={() => onUnassign()} className="text-muted-foreground gap-2">
194
+ <UserMinus size={13} /> Unassign
195
+ </DropdownMenuItem>
196
+ </>
197
+ ) : null}
198
+ </DropdownMenuContent>
199
+ </DropdownMenu>
200
+ )
201
+ }
202
+
203
+ /* ── Account owner(s) (Salesforce, read-through) ─────────────────────────── */
204
+
205
+ export interface AccountOwnerChipProps {
206
+ /** Salesforce account owners (RM, AE, …). Empty -> renders nothing. */
207
+ owners: OwnerPerson[]
208
+ className?: string
209
+ }
210
+
211
+ function AccountOwnerChip({ owners, className }: AccountOwnerChipProps) {
212
+ const [open, setOpen] = React.useState(false)
213
+ if (!owners.length) return null
214
+ const multi = owners.length > 1
215
+
216
+ // Single owner: a quiet, static chip — no dropdown (per product intent).
217
+ if (!multi) {
218
+ const only = owners[0]
219
+ const body = (
220
+ <>
221
+ <span className="inline-flex shrink-0 items-center">
222
+ <SalesforceMark />
223
+ </span>
224
+ <OwnerAvatar person={only} />
225
+ <span className="text-foreground font-medium">{only.name.split(" ")[0]}</span>
226
+ </>
227
+ )
228
+ return only.href ? (
229
+ <a
230
+ href={only.href}
231
+ target="_blank"
232
+ rel="noopener noreferrer"
233
+ data-slot="account-owner-chip"
234
+ className={cn(chipBase, "hover:bg-muted transition-colors", className)}
235
+ title={`Account owner in Salesforce — ${only.name}`}
236
+ >
237
+ {body}
238
+ <ArrowUpRight size={12} className="text-muted-foreground ml-0.5" />
239
+ </a>
240
+ ) : (
241
+ <span
242
+ data-slot="account-owner-chip"
243
+ className={cn(chipBase, className)}
244
+ title={`Account owner in Salesforce — ${only.name}`}
245
+ >
246
+ {body}
247
+ </span>
248
+ )
249
+ }
250
+
251
+ // Multiple owners: stacked avatars + ×N + a menu listing each.
252
+ return (
253
+ <DropdownMenu open={open} onOpenChange={setOpen}>
254
+ <DropdownMenuTrigger asChild>
255
+ <button
256
+ type="button"
257
+ data-slot="account-owner-chip"
258
+ data-multi="true"
259
+ className={cn(chipBase, "hover:bg-muted cursor-pointer transition-colors", className)}
260
+ title="Account owners in Salesforce"
261
+ >
262
+ <span className="inline-flex shrink-0 items-center">
263
+ <SalesforceMark />
264
+ </span>
265
+ <span className="flex -space-x-2">
266
+ {owners.map((o) => (
267
+ <OwnerAvatar key={o.id ?? o.name} person={o} />
268
+ ))}
269
+ </span>
270
+ <span className="text-foreground font-medium">Account owners</span>
271
+ <span className="bg-muted text-muted-foreground rounded px-1 text-[11px] font-semibold tabular-nums">
272
+ ×{owners.length}
273
+ </span>
274
+ <span className="text-muted-foreground ml-0.5">
275
+ {open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
276
+ </span>
277
+ </button>
278
+ </DropdownMenuTrigger>
279
+ <DropdownMenuContent align="start" className="w-72">
280
+ <DropdownMenuLabel className="flex items-center gap-1.5 text-[13px]">
281
+ <SalesforceMark />
282
+ {owners.length} account owners
283
+ </DropdownMenuLabel>
284
+ <DropdownMenuSeparator />
285
+ {owners.map((o) => {
286
+ const row = (
287
+ <>
288
+ <OwnerAvatar person={o} size="default" />
289
+ <span className="flex min-w-0 flex-col">
290
+ <span className="truncate text-[13px] font-medium">{o.name}</span>
291
+ {o.role ? (
292
+ <span className="text-muted-foreground truncate text-[11px]">{o.role}</span>
293
+ ) : null}
294
+ </span>
295
+ {o.href ? <ArrowUpRight size={13} className="text-muted-foreground ml-auto" /> : null}
296
+ </>
297
+ )
298
+ return (
299
+ <DropdownMenuItem key={o.id ?? o.name} asChild={!!o.href} className="gap-2">
300
+ {o.href ? (
301
+ <a href={o.href} target="_blank" rel="noopener noreferrer" title={`Open ${o.name} in Salesforce`}>
302
+ {row}
303
+ </a>
304
+ ) : (
305
+ <span>{row}</span>
306
+ )}
307
+ </DropdownMenuItem>
308
+ )
309
+ })}
310
+ <DropdownMenuSeparator />
311
+ <div className="text-muted-foreground flex items-center gap-1.5 px-2 py-1.5 text-[11px]">
312
+ <Info size={12} /> Synced from Salesforce. Manage owners there.
313
+ </div>
314
+ </DropdownMenuContent>
315
+ </DropdownMenu>
316
+ )
317
+ }
318
+
319
+ /* ── Convenience composite ──────────────────────────────────────────────── */
320
+
321
+ export interface OwnerChipsProps extends SignalOwnerChipProps {
322
+ /** Salesforce account owners (read-through). */
323
+ accountOwners?: OwnerPerson[]
324
+ }
325
+
326
+ function OwnerChips({ accountOwners = [], className, ...signal }: OwnerChipsProps) {
327
+ return (
328
+ <>
329
+ <SignalOwnerChip {...signal} className={className} />
330
+ <AccountOwnerChip owners={accountOwners} className={className} />
331
+ </>
332
+ )
333
+ }
334
+
335
+ export { SignalOwnerChip, AccountOwnerChip, OwnerChips }
@@ -50,8 +50,14 @@ export interface PriorityFactor {
50
50
  rationale: string
51
51
  }
52
52
 
53
+ export type SignalPriorityScoreDisplay = "label" | "number"
54
+
53
55
  export interface SignalPriorityPopoverProps {
54
56
  score: number
57
+ /** Controls whether the overall score number is shown in the popover header. @default "number" */
58
+ scoreDisplay?: SignalPriorityScoreDisplay
59
+ /** Short formula/context label shown beside the contributing factors heading. @default "Priority factors" */
60
+ formulaLabel?: string
55
61
  urgencyLabel?: SignalScoreUrgencyLabel
56
62
  /** Synthesis sentence displayed in the popover head. */
57
63
  urgencyExplanation?: string
@@ -282,6 +288,8 @@ export function SignalPriorityPopover({
282
288
  initialFactorFeedback,
283
289
  onFactorFeedback,
284
290
  initialPriorityFeedback,
291
+ scoreDisplay = "number",
292
+ formulaLabel = "Priority factors",
285
293
  }: SignalPriorityPopoverProps) {
286
294
  const urgencyLabel = providedLabel ?? getUrgencyLevel(score)
287
295
  const scoreRange = getUrgencyRange(urgencyLabel)
@@ -330,15 +338,20 @@ export function SignalPriorityPopover({
330
338
  data-testid="priority-popover-content"
331
339
  >
332
340
  {/* Head section */}
333
- <div className="p-4 pb-3">
341
+ <div className="p-4 pb-3" data-testid="priority-popover-header">
334
342
  <div className="flex items-start justify-between gap-3">
335
343
  <p className="text-sm font-semibold text-foreground">
336
344
  Why this is {urgencyLabel.toLowerCase()} priority
337
345
  </p>
338
- <span className="text-2xl font-bold tabular-nums text-foreground">
339
- {score}
340
- <span className="text-sm font-normal text-muted-foreground">/100</span>
341
- </span>
346
+ {scoreDisplay === "number" && (
347
+ <span
348
+ className="text-2xl font-bold tabular-nums text-foreground"
349
+ data-testid="priority-overall-score"
350
+ >
351
+ {score}
352
+ <span className="text-sm font-normal text-muted-foreground">/100</span>
353
+ </span>
354
+ )}
342
355
  </div>
343
356
 
344
357
  {/* Band indicator */}
@@ -369,7 +382,7 @@ export function SignalPriorityPopover({
369
382
  </span>
370
383
  <span className="flex items-center gap-1 text-[10px] text-muted-foreground">
371
384
  <Info className="h-3 w-3" />
372
- Score = weighted sum
385
+ {formulaLabel}
373
386
  </span>
374
387
  </div>
375
388
 
@@ -2,8 +2,25 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { cn } from "../lib/utils"
5
+ import { sanitizeHtml } from "../internal/safe-html"
5
6
  import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
6
7
 
8
+ /**
9
+ * Gmail-like reading-pane typography for rendered, sanitized email HTML.
10
+ * Self-contained Tailwind child-variant utilities — no global CSS. Links use
11
+ * Gmail blue; quoted history (`blockquote.gmail_quote`) is de-emphasized with a
12
+ * left rule and muted, slightly smaller text.
13
+ */
14
+ const EMAIL_HTML_CLASS = cn(
15
+ "text-sm leading-[1.62] text-foreground/90 break-words",
16
+ "[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
17
+ "[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
18
+ "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
19
+ "[&_img]:max-w-full [&_img]:h-auto",
20
+ "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_blockquote]:text-[13px]",
21
+ "[&_.gmail_quote]:border-l-2 [&_.gmail_quote]:border-border [&_.gmail_quote]:pl-3 [&_.gmail_quote]:text-muted-foreground [&_.gmail_quote]:text-[13px]"
22
+ )
23
+
7
24
  export type TimelineEventTone =
8
25
  | "red"
9
26
  | "amber"
@@ -37,6 +54,13 @@ export interface TimelineEvent {
37
54
  date?: string
38
55
  subject?: string
39
56
  body: React.ReactNode
57
+ /**
58
+ * HTML body. When provided, the card renders formatted, Gmail-like HTML
59
+ * instead of the plain-text `body`. The component sanitizes it before
60
+ * rendering. Opt-in: when absent, the plain-text `body` path is used and
61
+ * existing consumers are unaffected.
62
+ */
63
+ bodyHtml?: string
40
64
  }
41
65
  content?: React.ReactNode
42
66
  source?: {
@@ -242,9 +266,19 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
242
266
  </div>
243
267
  </div>
244
268
 
245
- <div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">
246
- {event.email.body}
247
- </div>
269
+ {event.email.bodyHtml ? (
270
+ // Gmail reading-pane typography; quoted history
271
+ // (blockquote.gmail_quote) is de-emphasized with a left rule.
272
+ <div
273
+ data-slot="timeline-email-html"
274
+ className={EMAIL_HTML_CLASS}
275
+ dangerouslySetInnerHTML={{ __html: sanitizeHtml(event.email.bodyHtml) }}
276
+ />
277
+ ) : (
278
+ <div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">
279
+ {event.email.body}
280
+ </div>
281
+ )}
248
282
 
249
283
  <button
250
284
  onClick={(e) => {
@@ -375,10 +375,10 @@ export function VirtualizedDataTable<TData>({
375
375
  onMouseDown={header.getResizeHandler()}
376
376
  onTouchStart={header.getResizeHandler()}
377
377
  className={cn(
378
- "absolute right-0 top-0 h-full w-3 -mr-1.5 cursor-col-resize select-none touch-none",
379
- "after:absolute after:right-1.5 after:top-0 after:h-full after:w-px",
380
- "after:bg-transparent hover:after:bg-primary/30",
381
- header.column.getIsResizing() && "after:bg-primary/50",
378
+ "absolute right-0 top-0 z-20 h-full w-4 -mr-2 cursor-col-resize select-none touch-none",
379
+ "after:absolute after:right-2 after:top-1 after:h-[calc(100%-0.5rem)] after:w-px after:rounded-full",
380
+ "after:bg-border/70 after:transition-colors hover:after:bg-primary/60",
381
+ header.column.getIsResizing() && "after:bg-primary/70",
382
382
  )}
383
383
  role="separator"
384
384
  aria-orientation="vertical"
package/src/index.ts CHANGED
@@ -29,10 +29,12 @@ export * from "./components/case-panel-activity-timeline"
29
29
  export * from "./components/case-panel-detail"
30
30
  export * from "./components/case-panel-why"
31
31
  export { CollapsibleSection, type CollapsibleSectionProps } from "./components/collapsible-section"
32
+ export * from "./components/comment-composer"
32
33
  export * from "./components/compliance-badge"
33
34
  export * from "./components/contact-chip"
34
35
  export * from "./components/contact-list"
35
36
  export * from "./components/contextual-quick-action-launcher"
37
+ export * from "./components/conversation-panel"
36
38
  export * from "./components/dashboard-cards"
37
39
  export * from "./components/data-table"
38
40
  export * from "./components/data-table-condition-filter"
@@ -50,7 +52,7 @@ export * from "./components/related-record-action-card"
50
52
  export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions, InlineFeedbackControl } from "./components/feedback-primitives"
51
53
  export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData, PersistedFeedbackData, InlineFeedbackControlProps } from "./components/feedback-primitives"
52
54
  export { SignalPriorityPopover } from "./components/signal-priority-popover"
53
- export type { SignalPriorityPopoverProps, PriorityFactor } from "./components/signal-priority-popover"
55
+ export type { SignalPriorityPopoverProps, SignalPriorityScoreDisplay, PriorityFactor } from "./components/signal-priority-popover"
54
56
  export * from "./components/filter-chip"
55
57
  export * from "./components/inbox-row"
56
58
  export * from "./components/inbox-toolbar"
@@ -67,6 +69,7 @@ export * from "./components/kbd-hint"
67
69
  export * from "./components/label"
68
70
  export * from "./components/message"
69
71
  export * from "./components/metric-card"
72
+ export * from "./components/owner-chips"
70
73
  export * from "./components/performance-metrics-table"
71
74
  export * from "./components/pill"
72
75
  export * from "./components/preview-list"
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { htmlToTextSnippet, sanitizeHtml } from "../safe-html"
3
+
4
+ describe("sanitizeHtml", () => {
5
+ it("removes executable tags, event handlers, styles, and unsafe urls", () => {
6
+ const html = sanitizeHtml(
7
+ '<p style="color:red" onclick="alert(1)">Hi<script>alert(1)</script><iframe src="https://evil.test"></iframe><a href="java&#x3a;script:alert(1)">bad</a><img src="data:text/html,boom" onerror="alert(1)"></p>',
8
+ )
9
+
10
+ expect(html).toBe('<p>Hi<a>bad</a><img></p>')
11
+ })
12
+
13
+ it("rejects entity-obfuscated unsafe protocols", () => {
14
+ const html = sanitizeHtml('<a href="jav&#x61;script&colon;alert(1)">bad</a><a href="java&amp;#x73;cript:alert(1)">also bad</a>')
15
+
16
+ expect(html).toBe('<a>bad</a><a>also bad</a>')
17
+ })
18
+
19
+ it("drops url attributes with out-of-range numeric entities without throwing", () => {
20
+ expect(() => sanitizeHtml('<a href="&#x110000;">link</a><img src="&#999999999999999999999;">')).not.toThrow()
21
+ expect(sanitizeHtml('<a href="&#x110000;">link</a><img src="&#999999999999999999999;">')).toBe('<a>link</a><img>')
22
+ })
23
+
24
+ it("drops protocol-relative urls instead of treating them as local paths", () => {
25
+ const html = sanitizeHtml('<a href="//evil.example/path">link</a><img src="//evil.example/pixel.png">')
26
+
27
+ expect(html).toBe('<a>link</a><img>')
28
+ })
29
+
30
+ it("handles less-than characters inside quoted attributes without preserving unsafe attributes", () => {
31
+ const html = sanitizeHtml('<img src=x onerror="alert(1)" alt="<"><p title="<" onclick="alert(1)">click me</p>')
32
+
33
+ expect(html).toBe('<img src="x" alt="&lt;"><p title="&lt;">click me</p>')
34
+ })
35
+
36
+ it("keeps common email formatting, safe links, and gmail_quote classes", () => {
37
+ const html = sanitizeHtml(
38
+ '<blockquote class="gmail_quote weird$class" style="color:red"><a href="https://example.com" target="_self">Quoted</a><br><ul><li>One</li></ul></blockquote>',
39
+ )
40
+
41
+ expect(html).toContain('class="gmail_quote"')
42
+ expect(html).toContain('href="https://example.com"')
43
+ expect(html).toContain('rel="noopener noreferrer"')
44
+ expect(html).toContain("<ul><li>One</li></ul>")
45
+ expect(html).not.toContain("style=")
46
+ })
47
+ })
48
+
49
+ describe("htmlToTextSnippet", () => {
50
+ it("builds snippets from sanitized text content", () => {
51
+ expect(htmlToTextSnippet('<p>Hello <script>alert(1)</script><b>world</b></p>', 11)).toBe("Hello world")
52
+ })
53
+ })