@handled-ai/design-system 0.18.22 → 0.18.24

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 (31) hide show
  1. package/dist/components/case-panel-activity-timeline.d.ts +100 -0
  2. package/dist/components/case-panel-activity-timeline.js +270 -0
  3. package/dist/components/case-panel-activity-timeline.js.map +1 -0
  4. package/dist/components/case-panel-detail.d.ts +60 -0
  5. package/dist/components/case-panel-detail.js +129 -0
  6. package/dist/components/case-panel-detail.js.map +1 -0
  7. package/dist/components/case-panel-email-composer.d.ts +61 -0
  8. package/dist/components/case-panel-email-composer.js +304 -0
  9. package/dist/components/case-panel-email-composer.js.map +1 -0
  10. package/dist/components/case-panel-why.d.ts +35 -0
  11. package/dist/components/case-panel-why.js +149 -0
  12. package/dist/components/case-panel-why.js.map +1 -0
  13. package/dist/components/contextual-quick-action-launcher.d.ts +7 -3
  14. package/dist/components/contextual-quick-action-launcher.js +99 -27
  15. package/dist/components/contextual-quick-action-launcher.js.map +1 -1
  16. package/dist/components/pill.d.ts +1 -1
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.js +4 -0
  19. package/dist/index.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/components/__tests__/case-panel-activity-timeline.test.tsx +152 -0
  22. package/src/components/__tests__/case-panel-detail.test.tsx +138 -0
  23. package/src/components/__tests__/case-panel-email-composer.test.tsx +171 -0
  24. package/src/components/__tests__/case-panel-why.test.tsx +152 -0
  25. package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +90 -0
  26. package/src/components/case-panel-activity-timeline.tsx +414 -0
  27. package/src/components/case-panel-detail.tsx +228 -0
  28. package/src/components/case-panel-email-composer.tsx +341 -0
  29. package/src/components/case-panel-why.tsx +214 -0
  30. package/src/components/contextual-quick-action-launcher.tsx +92 -15
  31. package/src/index.ts +4 -0
@@ -0,0 +1,414 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ChevronDown, ExternalLink } from "lucide-react"
5
+ import { cn } from "../lib/utils"
6
+ import { TONE_CLASSES, type TimelineEventTone } from "./timeline-activity"
7
+
8
+ export type CasePanelActivityTone = TimelineEventTone
9
+
10
+ export type CasePanelActivityActor =
11
+ | { kind: "system" }
12
+ | { kind: "integration"; name: string; iconUrl?: string }
13
+ | { kind: "user"; name: string; avatarUrl?: string; verb?: string }
14
+
15
+ export type CasePanelPayloadAction = {
16
+ kind: "openSignal"
17
+ key: string
18
+ eventId: string
19
+ }
20
+
21
+ export type CasePanelActivityPayload =
22
+ | {
23
+ kind: "signal"
24
+ key: string
25
+ summary: string
26
+ detail?: string
27
+ actionLabel?: string
28
+ }
29
+ | {
30
+ kind: "scoreUpdate"
31
+ label?: string
32
+ previousScore?: number
33
+ nextScore: number
34
+ reason?: string
35
+ }
36
+ | {
37
+ kind: "recommendation"
38
+ recommendation: string
39
+ rationale?: string
40
+ actionLabel?: string
41
+ }
42
+ | {
43
+ kind: "email"
44
+ from: string
45
+ to?: string
46
+ subject: string
47
+ preview?: string
48
+ body?: string
49
+ }
50
+ | {
51
+ kind: "salesforce"
52
+ objectLabel: string
53
+ recordLabel?: string
54
+ changeSummary: string
55
+ deepLink?: {
56
+ href: string
57
+ label?: string
58
+ }
59
+ }
60
+ | {
61
+ kind: "deadline"
62
+ dueLabel: string
63
+ status?: "upcoming" | "due" | "overdue" | "met"
64
+ description?: string
65
+ }
66
+ | {
67
+ kind: "operatorNote"
68
+ note: string
69
+ }
70
+ | {
71
+ kind: "assignment"
72
+ assignee: string
73
+ from?: string
74
+ role?: string
75
+ }
76
+ | {
77
+ kind: "caseOpened"
78
+ source?: string
79
+ openedBy?: string
80
+ description?: string
81
+ }
82
+ | {
83
+ kind: "generic"
84
+ description: string
85
+ metadata?: Array<{ label: string; value: string }>
86
+ }
87
+
88
+ export interface CasePanelActivityEvent {
89
+ id: string
90
+ title: string
91
+ timeLabel: string
92
+ tone: CasePanelActivityTone
93
+ actor?: CasePanelActivityActor
94
+ isSystemNoise?: boolean
95
+ payload: CasePanelActivityPayload
96
+ }
97
+
98
+ export interface CasePanelActivityTimelineProps {
99
+ events: CasePanelActivityEvent[]
100
+ className?: string
101
+ title?: string
102
+ defaultExpanded?: boolean
103
+ defaultShowSystemEvents?: boolean
104
+ onPayloadAction?: (action: CasePanelPayloadAction) => void
105
+ }
106
+
107
+ const NEUTRAL_DOT_CLASSES = "bg-background border-border/60"
108
+ const NEUTRAL_ICON_CLASSES = "text-muted-foreground"
109
+
110
+ const PAYLOAD_LABELS: Record<CasePanelActivityPayload["kind"], string> = {
111
+ signal: "Signal",
112
+ scoreUpdate: "Score update",
113
+ recommendation: "Recommendation",
114
+ email: "Email",
115
+ salesforce: "Salesforce",
116
+ deadline: "Deadline",
117
+ operatorNote: "Operator note",
118
+ assignment: "Assignment",
119
+ caseOpened: "Case opened",
120
+ generic: "Update",
121
+ }
122
+
123
+ const STATUS_CLASSES: Record<NonNullable<Extract<CasePanelActivityPayload, { kind: "deadline" }>["status"]>, string> = {
124
+ upcoming: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/30 dark:text-blue-300",
125
+ due: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300",
126
+ overdue: "border-red-200 bg-red-50 text-red-700 dark:border-red-900/40 dark:bg-red-950/30 dark:text-red-300",
127
+ met: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-300",
128
+ }
129
+
130
+ export function CasePanelActivityTimeline({
131
+ events,
132
+ className,
133
+ title = "Activity",
134
+ defaultExpanded = false,
135
+ defaultShowSystemEvents = false,
136
+ onPayloadAction,
137
+ }: CasePanelActivityTimelineProps) {
138
+ const [expanded, setExpanded] = React.useState(defaultExpanded)
139
+ const [showSystemEvents, setShowSystemEvents] = React.useState(defaultShowSystemEvents)
140
+
141
+ const nonSystemEvents = events.filter((event) => !event.isSystemNoise)
142
+ const visibleEvents = showSystemEvents ? events : nonSystemEvents
143
+ const systemNoiseCount = events.length - nonSystemEvents.length
144
+ const lastActivityText = nonSystemEvents[0]?.timeLabel ?? events[0]?.timeLabel ?? "No activity yet"
145
+
146
+ return (
147
+ <section className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-sm", className)}>
148
+ <button
149
+ type="button"
150
+ className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30"
151
+ aria-expanded={expanded}
152
+ onClick={() => setExpanded((current) => !current)}
153
+ >
154
+ <span className="min-w-0">
155
+ <span className="flex items-center gap-2">
156
+ <span className="text-sm font-semibold text-foreground">{title}</span>
157
+ <span className="rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
158
+ {nonSystemEvents.length} {nonSystemEvents.length === 1 ? "event" : "events"}
159
+ </span>
160
+ </span>
161
+ <span className="mt-1 block truncate text-xs text-muted-foreground">Last activity {lastActivityText}</span>
162
+ </span>
163
+ <ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")} />
164
+ </button>
165
+
166
+ {expanded ? (
167
+ <div className="border-t border-border px-4 py-4">
168
+ {systemNoiseCount > 0 ? (
169
+ <label className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
170
+ <span>Show system events</span>
171
+ <input
172
+ type="checkbox"
173
+ className="h-4 w-4 rounded border-border text-primary accent-current"
174
+ checked={showSystemEvents}
175
+ onChange={(event) => setShowSystemEvents(event.target.checked)}
176
+ />
177
+ </label>
178
+ ) : null}
179
+
180
+ {visibleEvents.length > 0 ? (
181
+ <div className="space-y-0">
182
+ {visibleEvents.map((event, index) => (
183
+ <CasePanelActivityTimelineItem
184
+ key={event.id}
185
+ event={event}
186
+ isLast={index === visibleEvents.length - 1}
187
+ onPayloadAction={onPayloadAction}
188
+ />
189
+ ))}
190
+ </div>
191
+ ) : (
192
+ <p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
193
+ No activity to show.
194
+ </p>
195
+ )}
196
+ </div>
197
+ ) : null}
198
+ </section>
199
+ )
200
+ }
201
+
202
+ function CasePanelActivityTimelineItem({
203
+ event,
204
+ isLast,
205
+ onPayloadAction,
206
+ }: {
207
+ event: CasePanelActivityEvent
208
+ isLast: boolean
209
+ onPayloadAction?: (action: CasePanelPayloadAction) => void
210
+ }) {
211
+ const toneStyle = TONE_CLASSES[event.tone]
212
+ const dotClasses = toneStyle ? toneStyle.dot : NEUTRAL_DOT_CLASSES
213
+ const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
214
+
215
+ return (
216
+ <article className="group relative flex gap-3.5" data-testid="case-panel-activity-event">
217
+ {!isLast ? <div className="absolute bottom-[-6px] left-[9px] top-5 w-px bg-border/60" /> : null}
218
+ <div className="relative z-10 mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-card">
219
+ <span
220
+ aria-hidden="true"
221
+ className={cn("flex h-4.5 w-4.5 items-center justify-center rounded-full border ring-4 ring-card", dotClasses, iconClasses)}
222
+ data-testid="case-panel-activity-dot"
223
+ >
224
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
225
+ </span>
226
+ </div>
227
+ <div className="min-w-0 flex-1 pb-5 pt-0.5">
228
+ <div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
229
+ <h3 className="pr-4 text-[13px] font-medium leading-relaxed text-foreground">{event.title}</h3>
230
+ <time className="mt-0.5 shrink-0 whitespace-nowrap text-[11px] text-muted-foreground/70">{event.timeLabel}</time>
231
+ </div>
232
+ <ActorByline actor={event.actor} timeLabel={event.timeLabel} />
233
+ <PayloadCard event={event} onPayloadAction={onPayloadAction} />
234
+ </div>
235
+ </article>
236
+ )
237
+ }
238
+
239
+ function ActorByline({ actor, timeLabel }: { actor?: CasePanelActivityActor; timeLabel: string }) {
240
+ if (!actor || actor.kind === "system") return null
241
+
242
+ if (actor.kind === "integration") {
243
+ return (
244
+ <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="case-panel-activity-byline">
245
+ {actor.iconUrl ? <img src={actor.iconUrl} alt="" className="h-4 w-4 rounded-sm object-cover" /> : null}
246
+ <span className="font-medium text-foreground">{actor.name}</span>
247
+ <span>synced this update</span>
248
+ <span className="text-muted-foreground/40">&middot;</span>
249
+ <span>{timeLabel}</span>
250
+ </div>
251
+ )
252
+ }
253
+
254
+ const verb = actor.verb ?? "updated this case"
255
+ const initial = actor.name.charAt(0).toUpperCase()
256
+
257
+ return (
258
+ <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="case-panel-activity-byline">
259
+ {actor.avatarUrl ? (
260
+ <img src={actor.avatarUrl} alt={actor.name} className="h-4 w-4 rounded-full object-cover" />
261
+ ) : (
262
+ <span className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/10 text-[8px] font-semibold text-muted-foreground">
263
+ {initial}
264
+ </span>
265
+ )}
266
+ <span className="font-medium text-foreground">{actor.name}</span>
267
+ <span>{verb}</span>
268
+ <span className="text-muted-foreground/40">&middot;</span>
269
+ <span>{timeLabel}</span>
270
+ </div>
271
+ )
272
+ }
273
+
274
+ function PayloadCard({
275
+ event,
276
+ onPayloadAction,
277
+ }: {
278
+ event: CasePanelActivityEvent
279
+ onPayloadAction?: (action: CasePanelPayloadAction) => void
280
+ }) {
281
+ const payload = event.payload
282
+
283
+ return (
284
+ <div className="mt-2 rounded-lg border border-border/80 bg-muted/20 px-3 py-2.5 text-sm" data-testid={`case-panel-payload-${payload.kind}`}>
285
+ <div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
286
+ {PAYLOAD_LABELS[payload.kind]}
287
+ </div>
288
+ {renderPayloadContent(payload, event.id, onPayloadAction)}
289
+ </div>
290
+ )
291
+ }
292
+
293
+ function renderPayloadContent(
294
+ payload: CasePanelActivityPayload,
295
+ eventId: string,
296
+ onPayloadAction?: (action: CasePanelPayloadAction) => void
297
+ ) {
298
+ switch (payload.kind) {
299
+ case "signal":
300
+ return (
301
+ <div className="space-y-2">
302
+ <p className="text-foreground">{payload.summary}</p>
303
+ {payload.detail ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.detail}</p> : null}
304
+ {onPayloadAction ? (
305
+ <button
306
+ type="button"
307
+ className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
308
+ onClick={() => onPayloadAction({ kind: "openSignal", key: payload.key, eventId })}
309
+ >
310
+ {payload.actionLabel ?? "Open signal"}
311
+ </button>
312
+ ) : null}
313
+ </div>
314
+ )
315
+ case "scoreUpdate":
316
+ return (
317
+ <div className="space-y-1">
318
+ <p className="text-foreground">
319
+ {payload.label ?? "Score"}: {payload.previousScore !== undefined ? `${payload.previousScore} → ` : ""}{payload.nextScore}
320
+ </p>
321
+ {payload.reason ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.reason}</p> : null}
322
+ </div>
323
+ )
324
+ case "recommendation":
325
+ return (
326
+ <div className="space-y-1">
327
+ <p className="font-medium text-foreground">{payload.recommendation}</p>
328
+ {payload.rationale ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.rationale}</p> : null}
329
+ {payload.actionLabel ? <p className="text-xs font-medium text-muted-foreground">{payload.actionLabel}</p> : null}
330
+ </div>
331
+ )
332
+ case "email":
333
+ return (
334
+ <div className="space-y-1">
335
+ <p className="font-medium text-foreground">{payload.subject}</p>
336
+ <p className="text-xs text-muted-foreground">
337
+ From {payload.from}{payload.to ? ` to ${payload.to}` : ""}
338
+ </p>
339
+ {payload.preview ? <p className="text-sm text-foreground/90">{payload.preview}</p> : null}
340
+ {payload.body ? <p className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">{payload.body}</p> : null}
341
+ </div>
342
+ )
343
+ case "salesforce":
344
+ return (
345
+ <div className="space-y-2">
346
+ <p className="text-foreground">
347
+ <span className="font-medium">{payload.objectLabel}</span>
348
+ {payload.recordLabel ? <span className="text-muted-foreground"> · {payload.recordLabel}</span> : null}
349
+ </p>
350
+ <p className="text-xs leading-relaxed text-muted-foreground">{payload.changeSummary}</p>
351
+ {payload.deepLink ? (
352
+ <a
353
+ href={payload.deepLink.href}
354
+ target="_blank"
355
+ rel="noreferrer noopener"
356
+ className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
357
+ >
358
+ {payload.deepLink.label ?? "Open in Salesforce"}
359
+ <ExternalLink className="h-3 w-3" />
360
+ </a>
361
+ ) : null}
362
+ </div>
363
+ )
364
+ case "deadline":
365
+ return (
366
+ <div className="space-y-2">
367
+ <div className="flex flex-wrap items-center gap-2">
368
+ <span className="font-medium text-foreground">{payload.dueLabel}</span>
369
+ {payload.status ? (
370
+ <span className={cn("rounded-full border px-2 py-0.5 text-[11px] font-medium", STATUS_CLASSES[payload.status])}>
371
+ {payload.status}
372
+ </span>
373
+ ) : null}
374
+ </div>
375
+ {payload.description ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.description}</p> : null}
376
+ </div>
377
+ )
378
+ case "operatorNote":
379
+ return <p className="whitespace-pre-line text-sm leading-relaxed text-foreground">{payload.note}</p>
380
+ case "assignment":
381
+ return (
382
+ <p className="text-foreground">
383
+ Assigned to <span className="font-medium">{payload.assignee}</span>
384
+ {payload.role ? <span className="text-muted-foreground"> as {payload.role}</span> : null}
385
+ {payload.from ? <span className="text-muted-foreground"> from {payload.from}</span> : null}
386
+ </p>
387
+ )
388
+ case "caseOpened":
389
+ return (
390
+ <div className="space-y-1">
391
+ <p className="text-foreground">
392
+ Case opened{payload.openedBy ? ` by ${payload.openedBy}` : ""}{payload.source ? ` from ${payload.source}` : ""}
393
+ </p>
394
+ {payload.description ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.description}</p> : null}
395
+ </div>
396
+ )
397
+ case "generic":
398
+ return (
399
+ <div className="space-y-2">
400
+ <p className="text-foreground">{payload.description}</p>
401
+ {payload.metadata && payload.metadata.length > 0 ? (
402
+ <dl className="grid gap-1 text-xs text-muted-foreground">
403
+ {payload.metadata.map((item) => (
404
+ <div key={`${item.label}-${item.value}`} className="flex gap-2">
405
+ <dt className="font-medium text-foreground">{item.label}</dt>
406
+ <dd>{item.value}</dd>
407
+ </div>
408
+ ))}
409
+ </dl>
410
+ ) : null}
411
+ </div>
412
+ )
413
+ }
414
+ }
@@ -0,0 +1,228 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ ArrowUpRight,
6
+ Check,
7
+ Copy,
8
+ ExternalLink,
9
+ } from "lucide-react"
10
+ import { cn } from "../lib/utils"
11
+
12
+ export type CasePanelDetailWidth = "comfortable" | "modest" | "full"
13
+
14
+ export interface CasePanelDetailProps {
15
+ children: React.ReactNode
16
+ /** Reading measure for the detail content column. Defaults to the handoff's comfortable 880px measure. */
17
+ width?: CasePanelDetailWidth
18
+ /** Accessible label for the detail region. */
19
+ "aria-label"?: string
20
+ className?: string
21
+ }
22
+
23
+ const detailWidthClasses: Record<CasePanelDetailWidth, string> = {
24
+ comfortable: "mx-auto w-full max-w-[880px] px-10 pb-[120px] pt-8",
25
+ modest: "mx-auto w-full max-w-[720px] px-8 pb-[112px] pt-7",
26
+ full: "w-full px-8 pb-[120px] pt-8",
27
+ }
28
+
29
+ export function CasePanelDetail({
30
+ children,
31
+ width = "comfortable",
32
+ "aria-label": ariaLabel = "Case detail",
33
+ className,
34
+ }: CasePanelDetailProps) {
35
+ return (
36
+ <section aria-label={ariaLabel} className={cn("min-w-0 bg-background", className)}>
37
+ <div className={detailWidthClasses[width]}>{children}</div>
38
+ </section>
39
+ )
40
+ }
41
+
42
+ export interface CasePanelHeaderProps {
43
+ title: React.ReactNode
44
+ /** Optional action, usually a Quick action trigger. */
45
+ action?: React.ReactNode
46
+ children?: React.ReactNode
47
+ className?: string
48
+ }
49
+
50
+ export function CasePanelHeader({
51
+ title,
52
+ action,
53
+ children,
54
+ className,
55
+ }: CasePanelHeaderProps) {
56
+ return (
57
+ <header className={cn("mb-7", className)}>
58
+ <div className="flex items-start justify-between gap-5">
59
+ <div className="min-w-0 flex-1">
60
+ <h1 className="m-0 text-[27px] font-bold leading-[1.18] tracking-[-0.02em] text-foreground [text-wrap:balance]">
61
+ {title}
62
+ </h1>
63
+ {children}
64
+ </div>
65
+ {action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
66
+ </div>
67
+ </header>
68
+ )
69
+ }
70
+
71
+ export interface CasePanelIdentityLink {
72
+ id: string
73
+ label: string
74
+ href?: string
75
+ icon?: React.ReactNode
76
+ kind?: "icon" | "text"
77
+ disabled?: boolean
78
+ }
79
+
80
+ export interface CasePanelIdentitySublineProps {
81
+ callsign: string
82
+ links?: CasePanelIdentityLink[]
83
+ onCopyCallsign?: (callsign: string) => void
84
+ copyLabel?: string
85
+ copiedLabel?: string
86
+ className?: string
87
+ }
88
+
89
+ export function CasePanelIdentitySubline({
90
+ callsign,
91
+ links = [],
92
+ onCopyCallsign,
93
+ copyLabel = "Copy call sign",
94
+ copiedLabel = "Call sign copied",
95
+ className,
96
+ }: CasePanelIdentitySublineProps) {
97
+ const [copied, setCopied] = React.useState(false)
98
+ const normalizedCallsign = callsign.startsWith("@") ? callsign : `@${callsign}`
99
+
100
+ const handleCopy = React.useCallback(() => {
101
+ onCopyCallsign?.(normalizedCallsign)
102
+ setCopied(true)
103
+ window.setTimeout(() => setCopied(false), 1400)
104
+ }, [normalizedCallsign, onCopyCallsign])
105
+
106
+ return (
107
+ <div className={cn("mt-[9px] inline-flex flex-wrap items-center gap-[7px] text-[13px] text-muted-foreground", className)}>
108
+ <span className="font-mono font-medium tracking-[0.01em] text-gray-700">{normalizedCallsign}</span>
109
+ <button
110
+ type="button"
111
+ onClick={handleCopy}
112
+ aria-label={copied ? copiedLabel : copyLabel}
113
+ className="inline-flex h-[22px] w-[22px] items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-accent hover:text-foreground"
114
+ >
115
+ {copied ? <Check className="h-[13px] w-[13px] text-emerald-700" aria-hidden="true" /> : <Copy className="h-[13px] w-[13px]" aria-hidden="true" />}
116
+ </button>
117
+ {links.length > 0 ? (
118
+ <>
119
+ <span aria-hidden="true" className="mx-[3px] h-3.5 w-px bg-gray-200" />
120
+ <span className="inline-flex items-center gap-1.5">
121
+ {links.map((link) => (
122
+ <CasePanelIdentityLinkButton key={link.id} link={link} />
123
+ ))}
124
+ </span>
125
+ </>
126
+ ) : null}
127
+ </div>
128
+ )
129
+ }
130
+
131
+ function CasePanelIdentityLinkButton({ link }: { link: CasePanelIdentityLink }) {
132
+ const disabled = link.disabled || !link.href
133
+ const iconOnly = link.kind === "icon"
134
+ const content = iconOnly ? (
135
+ <>{link.icon ?? <ExternalLink className="h-[13px] w-[13px]" aria-hidden="true" />}</>
136
+ ) : (
137
+ <>
138
+ {link.icon ? <span className="inline-flex h-3.5 w-3.5 items-center justify-center">{link.icon}</span> : null}
139
+ <span>{link.label}</span>
140
+ <ArrowUpRight className="h-[11px] w-[11px] text-gray-400" aria-hidden="true" />
141
+ </>
142
+ )
143
+
144
+ const className = iconOnly
145
+ ? "inline-flex h-[26px] w-[26px] items-center justify-center rounded-[7px] border border-border bg-background text-foreground shadow-[0_1px_1.5px_rgba(0,0,0,0.03)] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-45"
146
+ : "inline-flex h-[26px] items-center gap-1.5 rounded-[7px] border border-border bg-background px-2 text-xs font-medium text-foreground shadow-[0_1px_1.5px_rgba(0,0,0,0.03)] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-45"
147
+
148
+ if (disabled) {
149
+ return (
150
+ <button type="button" disabled aria-label={link.label} className={className}>
151
+ {content}
152
+ </button>
153
+ )
154
+ }
155
+
156
+ return (
157
+ <a href={link.href} aria-label={link.label} className={className}>
158
+ {content}
159
+ </a>
160
+ )
161
+ }
162
+
163
+ export interface CasePanelSignalBriefProps {
164
+ children: React.ReactNode
165
+ label?: string
166
+ className?: string
167
+ }
168
+
169
+ export function CasePanelSignalBrief({
170
+ children,
171
+ label = "Signal brief",
172
+ className,
173
+ }: CasePanelSignalBriefProps) {
174
+ return (
175
+ <section className={cn("mt-7", className)} aria-labelledby="case-panel-signal-brief-heading">
176
+ <div id="case-panel-signal-brief-heading" className="mb-2.5 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
177
+ {label}
178
+ </div>
179
+ <div className="text-base leading-[1.6] text-foreground [text-wrap:pretty]">{children}</div>
180
+ </section>
181
+ )
182
+ }
183
+
184
+ export interface CasePanelMetadataRowProps {
185
+ children: React.ReactNode
186
+ label?: string
187
+ className?: string
188
+ }
189
+
190
+ export function CasePanelMetadataRow({
191
+ children,
192
+ label = "Case metadata",
193
+ className,
194
+ }: CasePanelMetadataRowProps) {
195
+ return (
196
+ <div aria-label={label} className={cn("mt-[18px] flex flex-wrap items-center gap-2", className)}>
197
+ {children}
198
+ </div>
199
+ )
200
+ }
201
+
202
+ export interface CasePanelDetailSlotsProps {
203
+ why?: React.ReactNode
204
+ actions?: React.ReactNode
205
+ opportunity?: React.ReactNode
206
+ timeline?: React.ReactNode
207
+ playPanel?: React.ReactNode
208
+ className?: string
209
+ }
210
+
211
+ export function CasePanelDetailSlots({
212
+ why,
213
+ actions,
214
+ opportunity,
215
+ timeline,
216
+ playPanel,
217
+ className,
218
+ }: CasePanelDetailSlotsProps) {
219
+ return (
220
+ <div className={cn("mt-7 space-y-6", className)}>
221
+ {why ? <div data-slot="why">{why}</div> : null}
222
+ {actions ? <div data-slot="actions">{actions}</div> : null}
223
+ {opportunity ? <div data-slot="opportunity">{opportunity}</div> : null}
224
+ {timeline ? <div data-slot="timeline">{timeline}</div> : null}
225
+ {playPanel ? <div data-slot="play-panel">{playPanel}</div> : null}
226
+ </div>
227
+ )
228
+ }