@handled-ai/design-system 0.17.1 → 0.18.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.
- package/dist/charts/empty-chart-state.d.ts +11 -0
- package/dist/charts/empty-chart-state.js +70 -0
- package/dist/charts/empty-chart-state.js.map +1 -0
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.js +1 -0
- package/dist/charts/index.js.map +1 -1
- package/dist/charts/pipeline-overview.d.ts +2 -1
- package/dist/charts/pipeline-overview.js +29 -1
- package/dist/charts/pipeline-overview.js.map +1 -1
- package/dist/components/actor-byline.d.ts +3 -0
- package/dist/components/actor-byline.js +5 -0
- package/dist/components/actor-byline.js.map +1 -0
- package/dist/components/days-open-cell.d.ts +16 -0
- package/dist/components/days-open-cell.js +73 -0
- package/dist/components/days-open-cell.js.map +1 -0
- package/dist/components/detail-drawer.d.ts +16 -0
- package/dist/components/detail-drawer.js +45 -0
- package/dist/components/detail-drawer.js.map +1 -0
- package/dist/components/feedback-primitives.d.ts +66 -0
- package/dist/components/feedback-primitives.js +295 -0
- package/dist/components/feedback-primitives.js.map +1 -0
- package/dist/components/insights-filter-bar.d.ts +2 -1
- package/dist/components/insights-filter-bar.js +13 -5
- package/dist/components/insights-filter-bar.js.map +1 -1
- package/dist/components/linked-entity-cell.d.ts +14 -0
- package/dist/components/linked-entity-cell.js +96 -0
- package/dist/components/linked-entity-cell.js.map +1 -0
- package/dist/components/metric-card.d.ts +14 -1
- package/dist/components/metric-card.js +86 -0
- package/dist/components/metric-card.js.map +1 -1
- package/dist/components/performance-metrics-table.d.ts +2 -1
- package/dist/components/performance-metrics-table.js +78 -46
- package/dist/components/performance-metrics-table.js.map +1 -1
- package/dist/components/pill.d.ts +26 -0
- package/dist/components/pill.js +77 -0
- package/dist/components/pill.js.map +1 -0
- package/dist/components/quick-segment.d.ts +13 -0
- package/dist/components/quick-segment.js +96 -0
- package/dist/components/quick-segment.js.map +1 -0
- package/dist/components/score-why-chips.d.ts +8 -17
- package/dist/components/score-why-chips.js +266 -180
- package/dist/components/score-why-chips.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +17 -0
- package/dist/components/signal-priority-popover.js +247 -0
- package/dist/components/signal-priority-popover.js.map +1 -0
- package/dist/components/user-display.d.ts +22 -0
- package/dist/components/user-display.js +138 -0
- package/dist/components/user-display.js.map +1 -0
- package/dist/components/user-pill.d.ts +3 -0
- package/dist/components/user-pill.js +5 -0
- package/dist/components/user-pill.js.map +1 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/user-display.d.ts +31 -0
- package/dist/lib/user-display.js +57 -0
- package/dist/lib/user-display.js.map +1 -0
- package/dist/prototype/index.d.ts +2 -1
- package/dist/prototype/prototype-accounts-view.d.ts +2 -1
- package/dist/prototype/prototype-admin-view.d.ts +2 -1
- package/dist/prototype/prototype-config.d.ts +15 -332
- package/dist/prototype/prototype-inbox-view.d.ts +2 -1
- package/dist/prototype/prototype-inbox-view.js +11 -12
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +2 -1
- package/dist/prototype/prototype-shell.d.ts +2 -1
- package/dist/signal-priority-popover-DQ_VuHac.d.ts +390 -0
- package/package.json +1 -1
- package/src/charts/__tests__/insights-charts.test.tsx +62 -0
- package/src/charts/empty-chart-state.tsx +44 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/pipeline-overview.tsx +38 -1
- package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +99 -188
- package/src/components/__tests__/feedback-primitives.test.tsx +509 -0
- package/src/components/__tests__/insights-primitives.test.tsx +117 -0
- package/src/components/__tests__/performance-metrics-table.test.tsx +54 -0
- package/src/components/__tests__/score-why-chips.test.tsx +540 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +312 -0
- package/src/components/__tests__/user-display.test.tsx +75 -0
- package/src/components/actor-byline.tsx +1 -0
- package/src/components/days-open-cell.tsx +50 -0
- package/src/components/detail-drawer.tsx +60 -0
- package/src/components/feedback-primitives.tsx +424 -0
- package/src/components/insights-filter-bar.tsx +13 -4
- package/src/components/linked-entity-cell.tsx +74 -0
- package/src/components/metric-card.tsx +82 -0
- package/src/components/performance-metrics-table.tsx +99 -63
- package/src/components/pill.tsx +67 -0
- package/src/components/quick-segment.tsx +68 -0
- package/src/components/score-why-chips.tsx +413 -203
- package/src/components/signal-priority-popover.tsx +359 -0
- package/src/components/user-display.tsx +96 -0
- package/src/components/user-pill.tsx +1 -0
- package/src/index.ts +11 -0
- package/src/lib/__tests__/user-display.test.ts +85 -0
- package/src/lib/user-display.ts +88 -0
- package/src/prototype/__tests__/detail-view-score-why.test.tsx +33 -29
- package/src/prototype/__tests__/detail-view-title-slots.test.tsx +65 -0
- package/src/prototype/prototype-config.ts +28 -4
- package/src/prototype/prototype-inbox-view.tsx +8 -10
- package/src/prototype/__tests__/detail-view-title-subtext.test.tsx +0 -72
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
113
|
-
|
|
101
|
+
function resolveIcon(iconName?: string): LucideIcon {
|
|
102
|
+
if (!iconName) return Activity
|
|
103
|
+
return SIGNAL_TYPE_ICONS[iconName] ?? Activity
|
|
114
104
|
}
|
|
115
105
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
id?: string
|
|
157
|
-
}
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// CombinedSignalMiniChips - renders component type chips for combined signals
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
158
229
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 (
|
|
369
|
+
if (isClickable) {
|
|
245
370
|
return (
|
|
246
371
|
<button
|
|
247
372
|
type="button"
|
|
248
|
-
className="w-full rounded-md bg-background/80 p-
|
|
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-
|
|
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" : ""} – {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
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
<
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
)
|