@handled-ai/design-system 0.17.0 → 0.17.2

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 (51) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/feedback-primitives.d.ts +66 -0
  4. package/dist/components/feedback-primitives.js +295 -0
  5. package/dist/components/feedback-primitives.js.map +1 -0
  6. package/dist/components/score-why-chips.d.ts +8 -17
  7. package/dist/components/score-why-chips.js +266 -180
  8. package/dist/components/score-why-chips.js.map +1 -1
  9. package/dist/components/signal-priority-popover.d.ts +17 -0
  10. package/dist/components/signal-priority-popover.js +247 -0
  11. package/dist/components/signal-priority-popover.js.map +1 -0
  12. package/dist/components/tabs.d.ts +1 -1
  13. package/dist/components/user-display.d.ts +22 -0
  14. package/dist/components/user-display.js +138 -0
  15. package/dist/components/user-display.js.map +1 -0
  16. package/dist/components/user-pill.d.ts +3 -0
  17. package/dist/components/user-pill.js +5 -0
  18. package/dist/components/user-pill.js.map +1 -0
  19. package/dist/index.d.ts +6 -3
  20. package/dist/index.js +12 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/user-display.d.ts +31 -0
  23. package/dist/lib/user-display.js +57 -0
  24. package/dist/lib/user-display.js.map +1 -0
  25. package/dist/prototype/index.d.ts +2 -1
  26. package/dist/prototype/prototype-accounts-view.d.ts +2 -1
  27. package/dist/prototype/prototype-admin-view.d.ts +2 -1
  28. package/dist/prototype/prototype-config.d.ts +15 -328
  29. package/dist/prototype/prototype-inbox-view.d.ts +8 -3
  30. package/dist/prototype/prototype-inbox-view.js +24 -13
  31. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  32. package/dist/prototype/prototype-insights-view.d.ts +2 -1
  33. package/dist/prototype/prototype-shell.d.ts +2 -1
  34. package/dist/signal-priority-popover-DQ_VuHac.d.ts +390 -0
  35. package/package.json +1 -1
  36. package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +99 -188
  37. package/src/components/__tests__/feedback-primitives.test.tsx +509 -0
  38. package/src/components/__tests__/score-why-chips.test.tsx +540 -0
  39. package/src/components/__tests__/signal-priority-popover.test.tsx +312 -0
  40. package/src/components/feedback-primitives.tsx +424 -0
  41. package/src/components/score-why-chips.tsx +413 -203
  42. package/src/components/signal-priority-popover.tsx +359 -0
  43. package/src/components/user-display.tsx +96 -0
  44. package/src/components/user-pill.tsx +1 -0
  45. package/src/index.ts +6 -0
  46. package/src/lib/__tests__/user-display.test.ts +43 -0
  47. package/src/lib/user-display.ts +88 -0
  48. package/src/prototype/__tests__/detail-view-score-why.test.tsx +33 -29
  49. package/src/prototype/__tests__/detail-view-title-slots.test.tsx +65 -0
  50. package/src/prototype/prototype-config.ts +28 -0
  51. package/src/prototype/prototype-inbox-view.tsx +25 -11
@@ -1,8 +1,21 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { Info } from "lucide-react"
5
- import { ScoreBreakdown, type ScoreFactor } from "./score-breakdown"
4
+ import {
5
+ ChevronDown,
6
+ ChevronUp,
7
+ ChevronRight,
8
+ X,
9
+ TrendingDown,
10
+ ArrowUpRight,
11
+ Radar,
12
+ ArrowDownLeft,
13
+ GitMerge,
14
+ Activity,
15
+ } from "lucide-react"
16
+ import type { LucideIcon } from "lucide-react"
17
+ import { FeedbackFooter } from "./feedback-primitives"
18
+ import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
6
19
  import { cn } from "../lib/utils"
7
20
  import type {
8
21
  QueueItem,
@@ -12,6 +25,10 @@ import type {
12
25
  SignalScoreUrgencyLabel,
13
26
  } from "../prototype/prototype-config"
14
27
 
28
+ // ---------------------------------------------------------------------------
29
+ // Constants & helpers
30
+ // ---------------------------------------------------------------------------
31
+
15
32
  export function getSignalScoreUrgencyLabel(
16
33
  score: number,
17
34
  providedLabel?: SignalScoreUrgencyLabel,
@@ -23,28 +40,7 @@ export function getSignalScoreUrgencyLabel(
23
40
  return "Low"
24
41
  }
25
42
 
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 {
43
+ export function scoreRangeForUrgency(label: SignalScoreUrgencyLabel): string {
48
44
  switch (label) {
49
45
  case "Urgent":
50
46
  return "80-100"
@@ -64,18 +60,6 @@ function makeDomId(...parts: Array<string | undefined>): string {
64
60
  .replace(/[^A-Za-z0-9_-]+/g, "-")
65
61
  }
66
62
 
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
63
  function bucketHasSignalRows(bucket: SignalScoreExplanationBucket): boolean {
80
64
  return (
81
65
  (bucket.signals?.length ?? 0) > 0 ||
@@ -102,120 +86,253 @@ function getBucketSignals(bucket: SignalScoreExplanationBucket): SignalScoreExpl
102
86
  }))
103
87
  }
104
88
 
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)
89
+ // ---------------------------------------------------------------------------
90
+ // Signal type icon map - keyed by signal type name (Tailwind v4 source scanned)
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const SIGNAL_TYPE_ICONS: Record<string, LucideIcon> = {
94
+ treasury_liquidation: TrendingDown,
95
+ cumulative_treasury_outflow: ArrowUpRight,
96
+ test_transaction: Radar,
97
+ micro_deposit: ArrowDownLeft,
98
+ combined_signal: GitMerge,
110
99
  }
111
100
 
112
- function isSameExplanation(a?: string, b?: string): boolean {
113
- return Boolean(a && b && a.trim() === b.trim())
101
+ function resolveIcon(iconName?: string): LucideIcon {
102
+ if (!iconName) return Activity
103
+ return SIGNAL_TYPE_ICONS[iconName] ?? Activity
114
104
  }
115
105
 
116
- export interface SignalPriorityChipProps {
117
- score: number
118
- urgencyLabel?: SignalScoreUrgencyLabel
119
- isOpen?: boolean
120
- controlsId?: string
121
- onClick?: () => void
122
- className?: string
106
+ // ---------------------------------------------------------------------------
107
+ // Static tone class maps (REQUIRED for Tailwind v4 source scanning)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** Shared tone-to-class map. Re-exported for signal-priority-popover. */
111
+ export const SIGNAL_TONE_CLASSES: Record<string, string> = {
112
+ alert: "bg-red-50 text-red-600",
113
+ warn: "bg-amber-50 text-amber-600",
114
+ info: "bg-blue-50 text-blue-600",
123
115
  }
124
116
 
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)
117
+ /** Default tone for missing/unknown tone values */
118
+ export const DEFAULT_TONE_CLASS = "bg-muted text-muted-foreground"
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Em-dash fallback for missing slot data
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function slotValue(value: string | null | undefined): string {
125
+ return value && value.trim().length > 0 ? value : ""
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Bucket feedback chip config
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const BUCKET_NEGATIVE_CHIPS: FeedbackChipTree[] = [
133
+ {
134
+ label: "Not relevant for this account",
135
+ subPrompt: "Why isn't it relevant?",
136
+ subChips: [
137
+ "Business as usual for this account",
138
+ "Account in maintenance mode",
139
+ "Wrong contact for this signal",
140
+ "Other",
141
+ ],
142
+ },
143
+ { label: "Bad timing" },
144
+ {
145
+ label: "Inaccurate data",
146
+ subPrompt: "Which field?",
147
+ subChips: ["Balance figures", "Counterparty", "Timestamp", "Other"],
148
+ },
149
+ { label: "Wrong account" },
150
+ { label: "Already handled" },
151
+ { label: "Other" },
152
+ ]
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Default visible row count for long lists
156
+ // ---------------------------------------------------------------------------
157
+
158
+ const DEFAULT_VISIBLE_ROWS = 8
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // WhyPill - Bucket toggle button with icon, count badge, chevron, close
162
+ // ---------------------------------------------------------------------------
163
+
164
+ interface WhyPillProps {
165
+ bucket: SignalScoreExplanationBucket
166
+ isSelected: boolean
167
+ signalCount: number
168
+ panelId: string
169
+ onToggle: () => void
170
+ onClose: () => void
171
+ }
172
+
173
+ function WhyPill({ bucket, isSelected, signalCount, panelId, onToggle, onClose }: WhyPillProps) {
174
+ const IconComponent = resolveIcon(bucket.icon)
175
+
176
+ const sharedClasses = cn(
177
+ "inline-flex items-center text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
178
+ isSelected
179
+ ? "border-border bg-muted text-foreground"
180
+ : "border-border bg-background text-muted-foreground hover:bg-muted/60 hover:text-foreground",
181
+ )
134
182
 
135
183
  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,
184
+ <div className="inline-flex h-[26px] items-stretch">
185
+ <button
186
+ type="button"
187
+ onClick={onToggle}
188
+ aria-expanded={isSelected}
189
+ aria-controls={panelId}
190
+ className={cn(
191
+ sharedClasses,
192
+ "gap-1.5 rounded-lg border px-2.5 py-1",
193
+ isSelected && "rounded-b-none rounded-r-none border-r-0",
194
+ )}
195
+ >
196
+ <IconComponent className="h-3 w-3 shrink-0" />
197
+ {bucket.label}
198
+ {signalCount > 1 && (
199
+ <span className={cn("rounded-full px-1.5 py-0 text-[10px]", isSelected ? "bg-background/60" : "bg-muted")}>
200
+ x{signalCount}
201
+ </span>
202
+ )}
203
+ {isSelected ? (
204
+ <ChevronUp className="h-3 w-3 shrink-0" />
205
+ ) : (
206
+ <ChevronDown className="h-3 w-3 shrink-0" />
207
+ )}
208
+ </button>
209
+ {isSelected && (
210
+ <button
211
+ type="button"
212
+ aria-label={`Close ${bucket.label}`}
213
+ onClick={onClose}
214
+ className={cn(
215
+ sharedClasses,
216
+ "rounded-lg rounded-b-none rounded-l-none border border-l-0 border-border px-1.5 py-1 hover:bg-background/60",
217
+ )}
218
+ >
219
+ <X className="h-3 w-3" />
220
+ </button>
145
221
  )}
146
- >
147
- {urgencyLabel} Priority
148
- <Info className="h-3 w-3" />
149
- </button>
222
+ </div>
150
223
  )
151
224
  }
152
225
 
153
- export interface SignalPriorityPanelProps {
154
- signalData: SignalScoreData
155
- className?: string
156
- id?: string
157
- }
226
+ // ---------------------------------------------------------------------------
227
+ // CombinedSignalMiniChips - renders component type chips for combined signals
228
+ // ---------------------------------------------------------------------------
158
229
 
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)
230
+ interface CombinedSignalMiniChipsProps {
231
+ components: Array<{ type: string; count: number }>
232
+ }
170
233
 
234
+ function CombinedSignalMiniChips({ components }: CombinedSignalMiniChipsProps) {
171
235
  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>
236
+ <div className="flex flex-wrap items-center gap-1">
237
+ {components.map((comp, idx) => {
238
+ const CompIcon = resolveIcon(comp.type)
239
+ return (
240
+ <React.Fragment key={comp.type}>
241
+ {idx > 0 && <span className="text-[10px] text-muted-foreground/60">+</span>}
242
+ <span className="inline-flex items-center gap-0.5 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
243
+ <CompIcon className="h-2.5 w-2.5 shrink-0" />
244
+ {comp.type.replace(/_/g, " ")} x{comp.count}
245
+ </span>
246
+ </React.Fragment>
247
+ )
248
+ })}
249
+ </div>
250
+ )
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // StructuredSignalRow - CSS grid slot grammar signal row
255
+ // ---------------------------------------------------------------------------
256
+
257
+ interface StructuredSignalRowProps {
258
+ item: QueueItem
259
+ bucketKey: string
260
+ signal: SignalScoreExplanationSignal
261
+ tone?: "alert" | "warn" | "info"
262
+ onOpenSignalBucket?: ScoreWhyChipsProps["onOpenSignalBucket"]
263
+ }
264
+
265
+ function StructuredSignalRow({ item, bucketKey, signal, tone, onOpenSignalBucket }: StructuredSignalRowProps) {
266
+ const IconComponent = resolveIcon(signal.signalTypeName)
267
+ const toneClass = tone ? (SIGNAL_TONE_CLASSES[tone] ?? DEFAULT_TONE_CLASS) : DEFAULT_TONE_CLASS
268
+ const isCombined = signal.signalTypeName === "combined_signal" && signal.components && signal.components.length > 0
269
+
270
+ const rowContent = (
271
+ <>
272
+ {/* Slot 1: Icon */}
273
+ <div className={cn("flex h-5 w-5 shrink-0 items-center justify-center rounded", toneClass)}>
274
+ <IconComponent className="h-3 w-3" />
182
275
  </div>
183
276
 
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
- )}
277
+ {/* Slot 2: Primary value + qualifier */}
278
+ <div className="min-w-0">
279
+ {isCombined ? (
280
+ <CombinedSignalMiniChips components={signal.components!} />
281
+ ) : (
282
+ <div className="flex items-baseline gap-1.5">
283
+ <span className="text-sm font-semibold tabular-nums text-foreground">
284
+ {slotValue(signal.primaryValue)}
285
+ </span>
286
+ <span className="text-xs text-muted-foreground">
287
+ {slotValue(signal.qualifier)}
288
+ </span>
289
+ </div>
290
+ )}
291
+ </div>
200
292
 
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}
293
+ {/* Slot 3: Counterparty */}
294
+ <div className="min-w-0">
295
+ <span className="block truncate text-xs text-muted-foreground">
296
+ {slotValue(signal.counterparty)}
297
+ </span>
298
+ </div>
299
+
300
+ {/* Slot 4: Time */}
301
+ <span className="shrink-0 text-[11px] text-muted-foreground/70">
302
+ {slotValue(signal.time)}
303
+ </span>
304
+
305
+ {/* Slot 5: Chevron */}
306
+ <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/50" />
307
+ </>
308
+ )
309
+
310
+ if (signal.id && onOpenSignalBucket) {
311
+ return (
312
+ <button
313
+ type="button"
314
+ className="group grid w-full cursor-pointer items-center gap-x-3 gap-y-1 rounded-md px-3 py-2 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
315
+ style={{ gridTemplateColumns: "20px minmax(0,1fr) minmax(0,1fr) auto 16px" }}
316
+ onClick={() => onOpenSignalBucket({ item, bucketKey, signalId: signal.id! })}
317
+ >
318
+ {rowContent}
319
+ </button>
320
+ )
321
+ }
322
+
323
+ return (
324
+ <div
325
+ className="grid w-full items-center gap-x-3 gap-y-1 rounded-md px-3 py-2"
326
+ style={{ gridTemplateColumns: "20px minmax(0,1fr) minmax(0,1fr) auto 16px" }}
327
+ >
328
+ {rowContent}
209
329
  </div>
210
330
  )
211
331
  }
212
332
 
213
- export interface ScoreWhyChipsProps {
214
- item: QueueItem
215
- signalData: SignalScoreData
216
- onOpenSignalBucket?: (args: { item: QueueItem; bucketKey: string; signalId: string }) => void
217
- className?: string
218
- }
333
+ // ---------------------------------------------------------------------------
334
+ // Legacy SignalRow (for signals without structured data)
335
+ // ---------------------------------------------------------------------------
219
336
 
220
337
  interface SignalRowProps {
221
338
  item: QueueItem
@@ -224,36 +341,169 @@ interface SignalRowProps {
224
341
  onOpenSignalBucket?: ScoreWhyChipsProps["onOpenSignalBucket"]
225
342
  }
226
343
 
227
- function SignalRow({ item, bucketKey, signal, onOpenSignalBucket }: SignalRowProps) {
344
+ function LegacySignalRow({ item, bucketKey, signal, onOpenSignalBucket }: SignalRowProps) {
345
+ const isClickable = !!(signal.id && onOpenSignalBucket)
228
346
  const rowContent = (
229
347
  <>
230
348
  <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}
349
+ <div className="min-w-0 flex-1">
350
+ <p className="font-medium text-foreground">{signal.label}</p>
351
+ {signal.description ? <p className="mt-1 leading-relaxed text-muted-foreground">{signal.description}</p> : null}
352
+ {(signal.source || signal.metric) && (
353
+ <div className="mt-1.5 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground/80">
354
+ {signal.source ? <span className="rounded-full bg-muted px-2 py-0.5">{signal.source}</span> : null}
355
+ {signal.metric ? <span className="rounded-full bg-muted px-2 py-0.5">{signal.metric}</span> : null}
356
+ </div>
357
+ )}
239
358
  </div>
240
- )}
359
+ <div className="flex shrink-0 items-center gap-2">
360
+ {signal.time ? <span className="text-[11px] text-muted-foreground/70">{signal.time}</span> : null}
361
+ {isClickable && (
362
+ <ChevronRight className="h-3 w-3 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/50" />
363
+ )}
364
+ </div>
365
+ </div>
241
366
  </>
242
367
  )
243
368
 
244
- if (signal.id && onOpenSignalBucket) {
369
+ if (isClickable) {
245
370
  return (
246
371
  <button
247
372
  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! })}
373
+ className="group w-full cursor-pointer rounded-md bg-background/80 p-3 text-left text-xs transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
374
+ onClick={() => onOpenSignalBucket!({ item, bucketKey, signalId: signal.id! })}
250
375
  >
251
376
  {rowContent}
252
377
  </button>
253
378
  )
254
379
  }
255
380
 
256
- return <div className="rounded-md bg-background/80 p-2 text-xs">{rowContent}</div>
381
+ return <div className="rounded-md bg-background/80 p-3 text-xs">{rowContent}</div>
382
+ }
383
+
384
+ /**
385
+ * Determine whether a signal has structured slot data.
386
+ * If it has primaryValue, counterparty, qualifier, or components, use the structured row.
387
+ */
388
+ function hasStructuredData(signal: SignalScoreExplanationSignal): boolean {
389
+ return Boolean(
390
+ signal.primaryValue ||
391
+ signal.qualifier ||
392
+ signal.counterparty ||
393
+ (signal.components && signal.components.length > 0),
394
+ )
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // WhyCard - Expanded panel under a pill
399
+ // ---------------------------------------------------------------------------
400
+
401
+ interface WhyCardProps {
402
+ bucket: SignalScoreExplanationBucket
403
+ signals: SignalScoreExplanationSignal[]
404
+ item: QueueItem
405
+ panelId: string
406
+ onOpenSignalBucket?: ScoreWhyChipsProps["onOpenSignalBucket"]
407
+ onBucketFeedback?: (bucketKey: string, data: FeedbackSubmitData) => void
408
+ }
409
+
410
+ function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketFeedback }: WhyCardProps) {
411
+ const [showAll, setShowAll] = React.useState(false)
412
+ const [bucketFeedback, setBucketFeedback] = React.useState<"positive" | "negative" | null>(null)
413
+ const totalCount = bucket.signalCount ?? signals.length
414
+ const visibleSignals = showAll ? signals : signals.slice(0, DEFAULT_VISIBLE_ROWS)
415
+ const hiddenCount = signals.length - DEFAULT_VISIBLE_ROWS
416
+
417
+ // Determine whether to use structured rows (any signal has structured data)
418
+ const useStructured = signals.some(hasStructuredData)
419
+
420
+ return (
421
+ <div
422
+ id={panelId}
423
+ className="rounded-lg rounded-t-none border border-t-0 border-border bg-muted/20 p-3"
424
+ role="region"
425
+ aria-label={`${bucket.label} details`}
426
+ >
427
+ {/* Card header */}
428
+ <div className="mb-2 flex items-center justify-between">
429
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
430
+ {totalCount} signal{totalCount !== 1 ? "s" : ""} &ndash; {bucket.label}
431
+ </span>
432
+ </div>
433
+
434
+ {/* Signal rows */}
435
+ {visibleSignals.length > 0 ? (
436
+ <ul className="divide-y divide-border/30" aria-label="Matching signals">
437
+ {visibleSignals.map((signal, index) => (
438
+ <li key={signal.id ?? `${bucket.key}-signal-${index}`}>
439
+ {useStructured ? (
440
+ <StructuredSignalRow
441
+ item={item}
442
+ bucketKey={bucket.key}
443
+ signal={signal}
444
+ tone={bucket.tone}
445
+ onOpenSignalBucket={onOpenSignalBucket}
446
+ />
447
+ ) : (
448
+ <LegacySignalRow
449
+ item={item}
450
+ bucketKey={bucket.key}
451
+ signal={signal}
452
+ onOpenSignalBucket={onOpenSignalBucket}
453
+ />
454
+ )}
455
+ </li>
456
+ ))}
457
+ </ul>
458
+ ) : bucket.evidence && bucket.evidence.length > 0 ? (
459
+ <ul className="mt-3 space-y-1.5" aria-label="Matching signals">
460
+ {bucket.evidence.map((evidence, index) => (
461
+ <li key={`${bucket.key}-evidence-${index}`} className="flex gap-2 text-xs text-muted-foreground">
462
+ <span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-primary" />
463
+ <span className="leading-relaxed">{evidence}</span>
464
+ </li>
465
+ ))}
466
+ </ul>
467
+ ) : null}
468
+
469
+ {/* "Show N more" button */}
470
+ {!showAll && hiddenCount > 0 && (
471
+ <button
472
+ type="button"
473
+ onClick={() => setShowAll(true)}
474
+ className="mt-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
475
+ >
476
+ <ChevronDown className="h-3 w-3" />
477
+ Show {hiddenCount} more
478
+ </button>
479
+ )}
480
+
481
+ {/* Bucket feedback footer */}
482
+ {onBucketFeedback && (
483
+ <div className="mt-3 border-t border-border/40 pt-3">
484
+ <FeedbackFooter
485
+ feedback={bucketFeedback}
486
+ onFeedbackChange={setBucketFeedback}
487
+ onSubmit={(data) => onBucketFeedback(bucket.key, data)}
488
+ negativeChips={BUCKET_NEGATIVE_CHIPS}
489
+ negativePrompt="Was this bucket useful?"
490
+ positivePrompt="Thanks! What was useful about this bucket?"
491
+ />
492
+ </div>
493
+ )}
494
+ </div>
495
+ )
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // ScoreWhyChips - Main export
500
+ // ---------------------------------------------------------------------------
501
+
502
+ export interface ScoreWhyChipsProps {
503
+ item: QueueItem
504
+ signalData: SignalScoreData
505
+ onOpenSignalBucket?: (args: { item: QueueItem; bucketKey: string; signalId: string }) => void
506
+ className?: string
257
507
  }
258
508
 
259
509
  export function ScoreWhyChips({
@@ -286,72 +536,32 @@ export function ScoreWhyChips({
286
536
  {buckets.map((bucket) => {
287
537
  const isSelected = selectedBucketKey === bucket.key
288
538
  const panelId = `${idPrefix}-panel-${makeDomId(bucket.key)}`
539
+ const signals = getBucketSignals(bucket)
540
+ const signalCount = bucket.signalCount ?? signals.length
289
541
  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>
542
+ <div key={bucket.key} className="flex flex-col">
543
+ <WhyPill
544
+ bucket={bucket}
545
+ isSelected={isSelected}
546
+ signalCount={signalCount}
547
+ panelId={panelId}
548
+ onToggle={() => setSelectedBucketKey((prev) => (prev === bucket.key ? null : bucket.key))}
549
+ onClose={() => setSelectedBucketKey(null)}
550
+ />
551
+ </div>
308
552
  )
309
553
  })}
310
554
  </div>
311
555
 
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>
556
+ {selectedBucket && selectedPanelId && (
557
+ <WhyCard
558
+ bucket={selectedBucket}
559
+ signals={selectedBucketSignals}
560
+ item={item}
561
+ panelId={selectedPanelId}
562
+ onOpenSignalBucket={onOpenSignalBucket}
563
+ onBucketFeedback={signalData.onBucketFeedback}
564
+ />
355
565
  )}
356
566
  </div>
357
567
  )