@handled-ai/design-system 0.16.2 → 0.17.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,231 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ArrowRight, ChevronDown, Zap } from "lucide-react"
5
+
6
+ import { cn } from "../lib/utils"
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ } from "./dropdown-menu"
14
+
15
+ export interface ContextualQuickActionItem {
16
+ id: string
17
+ label: string
18
+ description?: string
19
+ icon?: React.ReactNode
20
+ disabled?: boolean
21
+ disabledReason?: string
22
+ }
23
+
24
+ export interface ContextualQuickActionContextLabelProps {
25
+ contextLabel: string
26
+ contextSecondary?: string
27
+ className?: string
28
+ }
29
+
30
+ export interface ContextualQuickActionLauncherProps {
31
+ contextLabel: string
32
+ contextSecondary?: string
33
+ items: ContextualQuickActionItem[]
34
+ onSelect: (item: ContextualQuickActionItem) => void
35
+ onBrowseAll?: () => void
36
+ showHint?: boolean
37
+ align?: "start" | "end"
38
+ className?: string
39
+ open?: boolean
40
+ defaultOpen?: boolean
41
+ onOpenChange?: (open: boolean) => void
42
+ }
43
+
44
+ function ContextualQuickActionContextLabel({
45
+ contextLabel,
46
+ contextSecondary,
47
+ className,
48
+ }: ContextualQuickActionContextLabelProps) {
49
+ return (
50
+ <div
51
+ data-slot="contextual-quick-action-context-label"
52
+ className={cn(
53
+ "-mx-1 -mt-1 mb-1 flex items-center gap-1.5 border-b px-3 py-2 text-[11px] text-muted-foreground",
54
+ className
55
+ )}
56
+ >
57
+ <Zap className="h-3 w-3 shrink-0" strokeWidth={2.25} aria-hidden="true" />
58
+ <span>Acting on</span>
59
+ <strong className="max-w-[180px] truncate font-semibold text-foreground">
60
+ {contextLabel}
61
+ </strong>
62
+ {contextSecondary ? (
63
+ <span className="min-w-0 truncate text-muted-foreground">
64
+ · {contextSecondary}
65
+ </span>
66
+ ) : null}
67
+ </div>
68
+ )
69
+ }
70
+
71
+ function DefaultActionIcon() {
72
+ return <Zap className="h-3.5 w-3.5" aria-hidden="true" />
73
+ }
74
+
75
+ function ContextualQuickActionLauncher({
76
+ contextLabel,
77
+ contextSecondary,
78
+ items,
79
+ onSelect,
80
+ onBrowseAll,
81
+ showHint = false,
82
+ align = "start",
83
+ className,
84
+ open,
85
+ defaultOpen,
86
+ onOpenChange,
87
+ }: ContextualQuickActionLauncherProps) {
88
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false)
89
+ const isControlled = open !== undefined
90
+ const isOpen = isControlled ? open : uncontrolledOpen
91
+
92
+ const handleOpenChange = React.useCallback(
93
+ (nextOpen: boolean) => {
94
+ if (!isControlled) {
95
+ setUncontrolledOpen(nextOpen)
96
+ }
97
+ onOpenChange?.(nextOpen)
98
+ },
99
+ [isControlled, onOpenChange]
100
+ )
101
+
102
+ const closeMenu = React.useCallback(() => {
103
+ handleOpenChange(false)
104
+ }, [handleOpenChange])
105
+
106
+ const handleSelect = React.useCallback(
107
+ (item: ContextualQuickActionItem, event?: Event) => {
108
+ if (item.disabled) {
109
+ event?.preventDefault()
110
+ return
111
+ }
112
+
113
+ onSelect(item)
114
+ closeMenu()
115
+ },
116
+ [closeMenu, onSelect]
117
+ )
118
+
119
+ const handleBrowseAll = React.useCallback(() => {
120
+ onBrowseAll?.()
121
+ closeMenu()
122
+ }, [closeMenu, onBrowseAll])
123
+
124
+ return (
125
+ <div
126
+ data-slot="contextual-quick-action-launcher"
127
+ className={cn("inline-flex items-center gap-2", className)}
128
+ >
129
+ <DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
130
+ <DropdownMenuTrigger asChild>
131
+ <button
132
+ type="button"
133
+ data-slot="contextual-quick-action-trigger"
134
+ data-state={isOpen ? "open" : "closed"}
135
+ className={cn(
136
+ "inline-flex h-8 items-center gap-2 rounded-lg border border-border bg-background py-1.5 pr-2.5 pl-2 text-xs font-medium text-foreground shadow-sm transition-colors hover:border-muted-foreground/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
137
+ "data-[state=open]:border-foreground data-[state=open]:bg-foreground data-[state=open]:text-background data-[state=open]:hover:bg-foreground"
138
+ )}
139
+ >
140
+ <span
141
+ className={cn(
142
+ "inline-flex h-[18px] w-[18px] items-center justify-center rounded bg-foreground/[0.04]",
143
+ isOpen && "bg-background/15"
144
+ )}
145
+ >
146
+ <Zap className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
147
+ </span>
148
+ <span className="tracking-[-0.005em]">Quick action</span>
149
+ <ChevronDown className="h-3 w-3 opacity-60" strokeWidth={2} aria-hidden="true" />
150
+ </button>
151
+ </DropdownMenuTrigger>
152
+
153
+ <DropdownMenuContent
154
+ align={align}
155
+ side="bottom"
156
+ sideOffset={6}
157
+ className="pointer-events-auto w-[308px] rounded-[10px] p-1.5 text-[12.5px] shadow-[0_12px_28px_rgba(0,0,0,0.12),0_2px_6px_rgba(0,0,0,0.04)]"
158
+ >
159
+ <ContextualQuickActionContextLabel
160
+ contextLabel={contextLabel}
161
+ contextSecondary={contextSecondary}
162
+ />
163
+
164
+ {items.map((item) => (
165
+ <DropdownMenuItem
166
+ key={item.id}
167
+ disabled={item.disabled}
168
+ onSelect={(event) => handleSelect(item, event)}
169
+ className={cn(
170
+ "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left outline-none focus:bg-accent data-[disabled]:cursor-not-allowed data-[disabled]:opacity-100",
171
+ item.disabled && "text-muted-foreground"
172
+ )}
173
+ >
174
+ <span
175
+ data-slot="contextual-quick-action-item-icon"
176
+ className={cn(
177
+ "flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-md bg-secondary text-foreground",
178
+ item.disabled && "opacity-60"
179
+ )}
180
+ >
181
+ {item.icon ?? <DefaultActionIcon />}
182
+ </span>
183
+ <span className="min-w-0 flex-1">
184
+ <span className="block truncate text-[12.5px] font-medium leading-tight text-current">
185
+ {item.label}
186
+ </span>
187
+ {item.description ? (
188
+ <span className="mt-0.5 block truncate text-[11px] leading-tight text-muted-foreground">
189
+ {item.description}
190
+ </span>
191
+ ) : null}
192
+ </span>
193
+ {item.disabled && item.disabledReason ? (
194
+ <span className="ml-auto shrink-0 text-[10.5px] italic text-muted-foreground/80">
195
+ {item.disabledReason}
196
+ </span>
197
+ ) : null}
198
+ </DropdownMenuItem>
199
+ ))}
200
+
201
+ <DropdownMenuSeparator className="-mx-1.5 my-1" />
202
+ <div className="flex items-center justify-between px-2 py-1.5 text-[11px] text-muted-foreground">
203
+ <button
204
+ type="button"
205
+ className="inline-flex items-center gap-1 font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
206
+ onClick={handleBrowseAll}
207
+ >
208
+ Browse all actions
209
+ <ArrowRight className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
210
+ </button>
211
+ <span className="inline-flex items-center rounded border border-border px-1 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
212
+ ⌘K
213
+ </span>
214
+ </div>
215
+ </DropdownMenuContent>
216
+ </DropdownMenu>
217
+
218
+ {showHint ? (
219
+ <span className="text-[11px] text-muted-foreground">
220
+ Or press{" "}
221
+ <span className="inline-flex items-center rounded border border-border bg-muted/40 px-1 py-0.5 text-[10px] font-medium leading-none">
222
+ ⌘K
223
+ </span>{" "}
224
+ for all actions
225
+ </span>
226
+ ) : null}
227
+ </div>
228
+ )
229
+ }
230
+
231
+ export { ContextualQuickActionContextLabel, ContextualQuickActionLauncher }
@@ -0,0 +1,358 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Info } from "lucide-react"
5
+ import { ScoreBreakdown, type ScoreFactor } from "./score-breakdown"
6
+ import { cn } from "../lib/utils"
7
+ import type {
8
+ QueueItem,
9
+ SignalScoreData,
10
+ SignalScoreExplanationBucket,
11
+ SignalScoreExplanationSignal,
12
+ SignalScoreUrgencyLabel,
13
+ } from "../prototype/prototype-config"
14
+
15
+ export function getSignalScoreUrgencyLabel(
16
+ score: number,
17
+ providedLabel?: SignalScoreUrgencyLabel,
18
+ ): SignalScoreUrgencyLabel {
19
+ if (providedLabel) return providedLabel
20
+ if (score >= 80) return "Urgent"
21
+ if (score >= 60) return "High"
22
+ if (score >= 35) return "Medium"
23
+ return "Low"
24
+ }
25
+
26
+ function getUrgencyChipClass(label: SignalScoreUrgencyLabel) {
27
+ switch (label) {
28
+ case "Urgent":
29
+ return "border-red-200 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"
30
+ case "High":
31
+ return "border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-900/50 dark:bg-orange-950/30 dark:text-orange-300"
32
+ case "Medium":
33
+ return "border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300"
34
+ case "Low":
35
+ return "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-300"
36
+ }
37
+ }
38
+
39
+ function classificationForScore(score?: number): string | undefined {
40
+ if (score == null) return undefined
41
+ if (score >= 80) return "Urgent"
42
+ if (score >= 60) return "High"
43
+ if (score >= 35) return "Medium"
44
+ return "Low"
45
+ }
46
+
47
+ function scoreRangeForUrgency(label: SignalScoreUrgencyLabel): string {
48
+ switch (label) {
49
+ case "Urgent":
50
+ return "80-100"
51
+ case "High":
52
+ return "60-79"
53
+ case "Medium":
54
+ return "35-59"
55
+ case "Low":
56
+ return "0-34"
57
+ }
58
+ }
59
+
60
+ function makeDomId(...parts: Array<string | undefined>): string {
61
+ return parts
62
+ .filter((part): part is string => Boolean(part))
63
+ .join("-")
64
+ .replace(/[^A-Za-z0-9_-]+/g, "-")
65
+ }
66
+
67
+ function scoreFactorToPriorityBucket(factor: ScoreFactor): SignalScoreExplanationBucket {
68
+ return {
69
+ key: factor.key,
70
+ label: factor.label,
71
+ kind: "factor",
72
+ score: factor.score ?? undefined,
73
+ classification: factor.risk ?? classificationForScore(factor.score ?? undefined),
74
+ rationale: factor.why,
75
+ factorKeys: [factor.key],
76
+ }
77
+ }
78
+
79
+ function bucketHasSignalRows(bucket: SignalScoreExplanationBucket): boolean {
80
+ return (
81
+ (bucket.signals?.length ?? 0) > 0 ||
82
+ (bucket.signalIds?.length ?? 0) > 0 ||
83
+ Boolean(bucket.primarySignalId)
84
+ )
85
+ }
86
+
87
+ function getSignalScoreBuckets(signalData: SignalScoreData): SignalScoreExplanationBucket[] {
88
+ return (signalData.explanationBuckets ?? []).filter(
89
+ (bucket) => bucket.kind !== "factor" && bucketHasSignalRows(bucket),
90
+ )
91
+ }
92
+
93
+ function getBucketSignals(bucket: SignalScoreExplanationBucket): SignalScoreExplanationSignal[] {
94
+ if (bucket.signals && bucket.signals.length > 0) return bucket.signals
95
+
96
+ const signalIds = bucket.signalIds && bucket.signalIds.length > 0 ? bucket.signalIds : bucket.primarySignalId ? [bucket.primarySignalId] : []
97
+ const uniqueSignalIds = Array.from(new Set(signalIds))
98
+
99
+ return uniqueSignalIds.map((signalId) => ({
100
+ id: signalId,
101
+ label: `${bucket.label} signal`,
102
+ }))
103
+ }
104
+
105
+ function getPriorityBuckets(signalData: SignalScoreData): SignalScoreExplanationBucket[] {
106
+ if (signalData.explanationBuckets !== undefined) return signalData.explanationBuckets
107
+ // Legacy fallback for consumers that still provide score factors but have not
108
+ // migrated to explanation buckets. WHY chips intentionally do not use this.
109
+ return signalData.factors.map(scoreFactorToPriorityBucket)
110
+ }
111
+
112
+ function isSameExplanation(a?: string, b?: string): boolean {
113
+ return Boolean(a && b && a.trim() === b.trim())
114
+ }
115
+
116
+ export interface SignalPriorityChipProps {
117
+ score: number
118
+ urgencyLabel?: SignalScoreUrgencyLabel
119
+ isOpen?: boolean
120
+ controlsId?: string
121
+ onClick?: () => void
122
+ className?: string
123
+ }
124
+
125
+ export function SignalPriorityChip({
126
+ score,
127
+ urgencyLabel: providedLabel,
128
+ isOpen,
129
+ controlsId,
130
+ onClick,
131
+ className,
132
+ }: SignalPriorityChipProps) {
133
+ const urgencyLabel = getSignalScoreUrgencyLabel(score, providedLabel)
134
+
135
+ return (
136
+ <button
137
+ type="button"
138
+ onClick={onClick}
139
+ aria-expanded={isOpen}
140
+ aria-controls={controlsId}
141
+ className={cn(
142
+ "inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
143
+ getUrgencyChipClass(urgencyLabel),
144
+ className,
145
+ )}
146
+ >
147
+ {urgencyLabel} Priority
148
+ <Info className="h-3 w-3" />
149
+ </button>
150
+ )
151
+ }
152
+
153
+ export interface SignalPriorityPanelProps {
154
+ signalData: SignalScoreData
155
+ className?: string
156
+ id?: string
157
+ }
158
+
159
+ export function SignalPriorityPanel({ signalData, className, id }: SignalPriorityPanelProps) {
160
+ const urgencyLabel = getSignalScoreUrgencyLabel(signalData.score, signalData.urgencyLabel)
161
+ const buckets = React.useMemo(() => getPriorityBuckets(signalData), [signalData])
162
+ const topBucketRationale = buckets.find((bucket) => bucket.rationale)?.rationale
163
+ const primaryUrgencyExplanation = signalData.urgencyExplanation ?? signalData.whyNow ?? topBucketRationale
164
+ const whyNowSection = isSameExplanation(signalData.whyNow, primaryUrgencyExplanation) ? undefined : signalData.whyNow
165
+ const topFactorSection =
166
+ isSameExplanation(topBucketRationale, primaryUrgencyExplanation) || isSameExplanation(topBucketRationale, whyNowSection)
167
+ ? undefined
168
+ : topBucketRationale
169
+ const scoreRange = scoreRangeForUrgency(urgencyLabel)
170
+
171
+ return (
172
+ <div id={id} className={cn("rounded-lg border border-border bg-muted/20 p-3 text-xs", className)} role="region" aria-label="Priority explanation">
173
+ <div className="flex flex-wrap items-start justify-between gap-3">
174
+ <div>
175
+ <p className="font-semibold text-foreground">Why this is {urgencyLabel.toLowerCase()} priority</p>
176
+ {primaryUrgencyExplanation ? <p className="mt-1 leading-relaxed text-muted-foreground">{primaryUrgencyExplanation}</p> : null}
177
+ </div>
178
+ <div className="flex shrink-0 flex-wrap gap-1.5 text-[11px] text-muted-foreground">
179
+ <span className="rounded-full bg-background px-2 py-0.5">Score {signalData.score}/100</span>
180
+ <span className="rounded-full bg-background px-2 py-0.5">{urgencyLabel} range: {scoreRange}</span>
181
+ </div>
182
+ </div>
183
+
184
+ {(whyNowSection || topFactorSection) && (
185
+ <div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
186
+ {whyNowSection ? (
187
+ <div className="rounded-md bg-background/80 p-2">
188
+ <p className="font-medium text-foreground">Why now</p>
189
+ <p className="mt-0.5 leading-relaxed">{whyNowSection}</p>
190
+ </div>
191
+ ) : null}
192
+ {topFactorSection ? (
193
+ <div className="rounded-md bg-background/80 p-2">
194
+ <p className="font-medium text-foreground">Top factor</p>
195
+ <p className="mt-0.5 leading-relaxed">{topFactorSection}</p>
196
+ </div>
197
+ ) : null}
198
+ </div>
199
+ )}
200
+
201
+ {signalData.factors.length > 0 ? (
202
+ <ScoreBreakdown
203
+ className="mt-3"
204
+ factors={signalData.factors}
205
+ onFactorFeedback={signalData.onFactorFeedback}
206
+ initialFeedback={signalData.initialFactorFeedback}
207
+ />
208
+ ) : null}
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export interface ScoreWhyChipsProps {
214
+ item: QueueItem
215
+ signalData: SignalScoreData
216
+ onOpenSignalBucket?: (args: { item: QueueItem; bucketKey: string; signalId: string }) => void
217
+ className?: string
218
+ }
219
+
220
+ interface SignalRowProps {
221
+ item: QueueItem
222
+ bucketKey: string
223
+ signal: SignalScoreExplanationSignal
224
+ onOpenSignalBucket?: ScoreWhyChipsProps["onOpenSignalBucket"]
225
+ }
226
+
227
+ function SignalRow({ item, bucketKey, signal, onOpenSignalBucket }: SignalRowProps) {
228
+ const rowContent = (
229
+ <>
230
+ <div className="flex items-start justify-between gap-2">
231
+ <p className="font-medium text-foreground">{signal.label}</p>
232
+ {signal.time ? <span className="shrink-0 text-[11px] text-muted-foreground/70">{signal.time}</span> : null}
233
+ </div>
234
+ {signal.description ? <p className="mt-1 leading-relaxed text-muted-foreground">{signal.description}</p> : null}
235
+ {(signal.source || signal.metric) && (
236
+ <div className="mt-1.5 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground/80">
237
+ {signal.source ? <span className="rounded-full bg-muted px-2 py-0.5">{signal.source}</span> : null}
238
+ {signal.metric ? <span className="rounded-full bg-muted px-2 py-0.5">{signal.metric}</span> : null}
239
+ </div>
240
+ )}
241
+ </>
242
+ )
243
+
244
+ if (signal.id && onOpenSignalBucket) {
245
+ return (
246
+ <button
247
+ type="button"
248
+ className="w-full rounded-md bg-background/80 p-2 text-left text-xs transition-colors hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
249
+ onClick={() => onOpenSignalBucket({ item, bucketKey, signalId: signal.id! })}
250
+ >
251
+ {rowContent}
252
+ </button>
253
+ )
254
+ }
255
+
256
+ return <div className="rounded-md bg-background/80 p-2 text-xs">{rowContent}</div>
257
+ }
258
+
259
+ export function ScoreWhyChips({
260
+ item,
261
+ signalData,
262
+ onOpenSignalBucket,
263
+ className,
264
+ }: ScoreWhyChipsProps) {
265
+ const [selectedBucketKey, setSelectedBucketKey] = React.useState<string | null>(null)
266
+
267
+ React.useEffect(() => {
268
+ setSelectedBucketKey(null)
269
+ }, [item.id])
270
+
271
+ const reactId = React.useId()
272
+ const idPrefix = makeDomId("score-why", reactId, item.id)
273
+ const buckets = React.useMemo(() => getSignalScoreBuckets(signalData), [signalData])
274
+ const selectedBucket = buckets.find((bucket) => bucket.key === selectedBucketKey) ?? null
275
+ const selectedBucketSignals = selectedBucket ? getBucketSignals(selectedBucket) : []
276
+ const selectedPanelId = selectedBucket ? `${idPrefix}-panel-${makeDomId(selectedBucket.key)}` : undefined
277
+
278
+ if (buckets.length === 0) return null
279
+
280
+ return (
281
+ <div className={cn("mt-4", className)}>
282
+ <div className="mb-2 flex items-center gap-2">
283
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Why</span>
284
+ </div>
285
+ <div className="flex flex-wrap gap-1.5">
286
+ {buckets.map((bucket) => {
287
+ const isSelected = selectedBucketKey === bucket.key
288
+ const panelId = `${idPrefix}-panel-${makeDomId(bucket.key)}`
289
+ return (
290
+ <button
291
+ key={bucket.key}
292
+ type="button"
293
+ onClick={() => setSelectedBucketKey((prev) => (prev === bucket.key ? null : bucket.key))}
294
+ aria-expanded={isSelected}
295
+ aria-controls={panelId}
296
+ className={cn(
297
+ "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
298
+ isSelected
299
+ ? "border-foreground/30 bg-foreground text-background"
300
+ : "border-border bg-background text-muted-foreground hover:bg-muted/60 hover:text-foreground",
301
+ )}
302
+ >
303
+ {bucket.label}
304
+ {bucket.signalCount && bucket.signalCount > 1 ? (
305
+ <span className={cn("rounded-full px-1.5 py-0 text-[10px]", isSelected ? "bg-background/20" : "bg-muted")}>×{bucket.signalCount}</span>
306
+ ) : null}
307
+ </button>
308
+ )
309
+ })}
310
+ </div>
311
+
312
+ {selectedBucket && (
313
+ <div
314
+ id={selectedPanelId}
315
+ className="mt-3 rounded-lg border border-border bg-muted/20 p-3"
316
+ role="region"
317
+ aria-label={`${selectedBucket.label} details`}
318
+ >
319
+ <div className="flex items-start justify-between gap-3">
320
+ <div>
321
+ <p className="text-sm font-semibold text-foreground">{selectedBucket.label}</p>
322
+ <div className="mt-1 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
323
+ <span className="rounded-full bg-background px-2 py-0.5">
324
+ {selectedBucket.signalCount ?? selectedBucketSignals.length} signals
325
+ </span>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ {selectedBucketSignals.length > 0 ? (
331
+ <ul className="mt-3 space-y-2" aria-label="Matching signals">
332
+ {selectedBucketSignals.map((signal, index) => (
333
+ <li key={signal.id ?? `${selectedBucket.key}-signal-${index}`}>
334
+ <SignalRow
335
+ item={item}
336
+ bucketKey={selectedBucket.key}
337
+ signal={signal}
338
+ onOpenSignalBucket={onOpenSignalBucket}
339
+ />
340
+ </li>
341
+ ))}
342
+ </ul>
343
+ ) : selectedBucket.evidence && selectedBucket.evidence.length > 0 ? (
344
+ <ul className="mt-3 space-y-1.5" aria-label="Matching signals">
345
+ {selectedBucket.evidence.map((evidence, index) => (
346
+ <li key={`${selectedBucket.key}-evidence-${index}`} className="flex gap-2 text-xs text-muted-foreground">
347
+ <span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-primary" />
348
+ <span className="leading-relaxed">{evidence}</span>
349
+ </li>
350
+ ))}
351
+ </ul>
352
+ ) : null}
353
+
354
+ </div>
355
+ )}
356
+ </div>
357
+ )
358
+ }
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export { CollapsibleSection, type CollapsibleSectionProps } from "./components/c
23
23
  export * from "./components/compliance-badge"
24
24
  export * from "./components/contact-chip"
25
25
  export * from "./components/contact-list"
26
+ export * from "./components/contextual-quick-action-launcher"
26
27
  export * from "./components/dashboard-cards"
27
28
  export * from "./components/data-table"
28
29
  export * from "./components/data-table-condition-filter"
@@ -66,6 +67,7 @@ export * from "./components/rich-text-toolbar"
66
67
  export * from "./components/score-analysis-modal"
67
68
  export * from "./components/score-breakdown"
68
69
  export * from "./components/score-feedback"
70
+ export * from "./components/score-why-chips"
69
71
  export * from "./components/score-ring"
70
72
  export * from "./components/scroll-area"
71
73
  export * from "./components/select"