@handled-ai/design-system 0.17.2 → 0.18.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 (41) hide show
  1. package/dist/charts/chart.d.ts +1 -1
  2. package/dist/components/actor-byline.d.ts +3 -0
  3. package/dist/components/actor-byline.js +5 -0
  4. package/dist/components/actor-byline.js.map +1 -0
  5. package/dist/components/feedback-primitives.d.ts +21 -2
  6. package/dist/components/feedback-primitives.js +90 -6
  7. package/dist/components/feedback-primitives.js.map +1 -1
  8. package/dist/components/performance-metrics-table.d.ts +2 -1
  9. package/dist/components/performance-metrics-table.js +78 -46
  10. package/dist/components/performance-metrics-table.js.map +1 -1
  11. package/dist/components/score-why-chips.d.ts +1 -1
  12. package/dist/components/score-why-chips.js +26 -5
  13. package/dist/components/score-why-chips.js.map +1 -1
  14. package/dist/components/signal-priority-popover.d.ts +1 -1
  15. package/dist/components/signal-priority-popover.js +172 -7
  16. package/dist/components/signal-priority-popover.js.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/prototype/index.d.ts +1 -1
  20. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  21. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  22. package/dist/prototype/prototype-config.d.ts +1 -1
  23. package/dist/prototype/prototype-inbox-view.d.ts +1 -1
  24. package/dist/prototype/prototype-inbox-view.js +4 -1
  25. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  26. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  27. package/dist/prototype/prototype-shell.d.ts +1 -1
  28. package/dist/{signal-priority-popover-DQ_VuHac.d.ts → signal-priority-popover-DWaAMhPI.d.ts} +26 -2
  29. package/package.json +3 -1
  30. package/src/components/__tests__/performance-metrics-table.test.tsx +54 -0
  31. package/src/components/__tests__/user-display.test.tsx +75 -0
  32. package/src/components/__tests__/wit-636-feedback-states.test.tsx +546 -0
  33. package/src/components/actor-byline.tsx +1 -0
  34. package/src/components/feedback-primitives.tsx +148 -26
  35. package/src/components/performance-metrics-table.tsx +99 -63
  36. package/src/components/score-why-chips.tsx +28 -2
  37. package/src/components/signal-priority-popover.tsx +194 -3
  38. package/src/index.ts +1 -1
  39. package/src/lib/__tests__/user-display.test.ts +53 -11
  40. package/src/prototype/prototype-config.ts +11 -1
  41. package/src/prototype/prototype-inbox-view.tsx +3 -0
@@ -17,10 +17,14 @@ import {
17
17
  ChevronDown,
18
18
  ChevronUp,
19
19
  Info,
20
+ ThumbsUp,
21
+ ThumbsDown,
22
+ Check,
23
+ Pencil,
20
24
  } from "lucide-react"
21
25
  import { cn } from "../lib/utils"
22
26
  import { FeedbackFooter } from "./feedback-primitives"
23
- import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
27
+ import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "./feedback-primitives"
24
28
  import type { SignalScoreUrgencyLabel } from "../prototype/prototype-config"
25
29
  import { getSignalScoreUrgencyLabel, scoreRangeForUrgency, SIGNAL_TONE_CLASSES } from "./score-why-chips"
26
30
 
@@ -58,6 +62,12 @@ export interface SignalPriorityPopoverProps {
58
62
  feedbackChips?: FeedbackChipTree[]
59
63
  onFeedbackSubmit?: (data: FeedbackSubmitData) => void
60
64
  className?: string
65
+ /** Persisted factor-level feedback (keyed by factor key). */
66
+ initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string; ownershipLabel?: string }>
67
+ /** Callback when user submits factor-level feedback. */
68
+ onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
69
+ /** Persisted priority-level feedback for the footer. */
70
+ initialPriorityFeedback?: PersistedFeedbackData | null
61
71
  }
62
72
 
63
73
  // ---------------------------------------------------------------------------
@@ -152,7 +162,13 @@ function DirectionIcon({ direction }: { direction: PriorityFactor["direction"] }
152
162
  // PriorityFactorRow
153
163
  // ---------------------------------------------------------------------------
154
164
 
155
- function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
165
+ interface PriorityFactorRowProps {
166
+ factor: PriorityFactor
167
+ initialFeedback?: { type: "up" | "down"; detail: string; ownershipLabel?: string }
168
+ onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
169
+ }
170
+
171
+ function PriorityFactorRow({ factor, initialFeedback, onFactorFeedback }: PriorityFactorRowProps) {
156
172
  const IconComponent = FACTOR_ICONS[factor.icon] ?? Activity
157
173
  const toneClasses = TONE_ICON_CLASSES[factor.tone]
158
174
  const directionClasses = DIRECTION_CLASSES[factor.direction]
@@ -163,6 +179,50 @@ function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
163
179
  ? "Lowers"
164
180
  : "Neutral"
165
181
 
182
+ const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(
183
+ initialFeedback?.type ?? null,
184
+ )
185
+ const [showInput, setShowInput] = React.useState(false)
186
+ const [detailText, setDetailText] = React.useState(initialFeedback?.detail ?? "")
187
+ const [saved, setSaved] = React.useState(!!initialFeedback)
188
+ const [savedDetail, setSavedDetail] = React.useState(initialFeedback?.detail ?? "")
189
+ const ownershipLabel = initialFeedback?.ownershipLabel ?? "Your feedback"
190
+
191
+ // Sync with initialFeedback prop changes
192
+ React.useEffect(() => {
193
+ if (initialFeedback) {
194
+ setThumbState(initialFeedback.type)
195
+ setSaved(true)
196
+ setSavedDetail(initialFeedback.detail)
197
+ }
198
+ }, [initialFeedback])
199
+
200
+ const handleThumbClick = React.useCallback(
201
+ (type: "up" | "down") => {
202
+ if (thumbState === type) {
203
+ // Toggle off
204
+ setThumbState(null)
205
+ setShowInput(false)
206
+ setSaved(false)
207
+ onFactorFeedback?.(factor.key, null)
208
+ } else {
209
+ setThumbState(type)
210
+ setShowInput(true)
211
+ setSaved(false)
212
+ }
213
+ },
214
+ [thumbState, factor.key, onFactorFeedback],
215
+ )
216
+
217
+ const handleSubmitDetail = React.useCallback(() => {
218
+ if (!thumbState) return
219
+ const text = detailText.trim()
220
+ onFactorFeedback?.(factor.key, thumbState, text)
221
+ setSaved(true)
222
+ setSavedDetail(text)
223
+ setShowInput(false)
224
+ }, [thumbState, detailText, factor.key, onFactorFeedback])
225
+
166
226
  return (
167
227
  <div
168
228
  className="grid grid-cols-[20px_1fr_auto] gap-x-3 gap-y-1 px-4 py-3"
@@ -224,6 +284,124 @@ function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
224
284
 
225
285
  {/* empty grid cell under score column */}
226
286
  <div />
287
+
288
+ {/* Factor-level feedback row (spans icon + content columns) */}
289
+ {onFactorFeedback && (
290
+ <>
291
+ <div />
292
+ <div className="col-span-2 mt-1">
293
+ {saved && !showInput ? (
294
+ /* Persisted / saved indicator */
295
+ <button
296
+ type="button"
297
+ onClick={() => {
298
+ setDetailText(savedDetail)
299
+ setShowInput(true)
300
+ setSaved(false)
301
+ }}
302
+ className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
303
+ data-testid={`factor-feedback-persisted-${factor.key}`}
304
+ >
305
+ <span className="font-medium">{ownershipLabel}:</span>
306
+ {thumbState === "up" ? (
307
+ <ThumbsUp className="h-[10px] w-[10px]" />
308
+ ) : (
309
+ <ThumbsDown className="h-[10px] w-[10px]" />
310
+ )}
311
+ {savedDetail && (
312
+ <span className="max-w-[180px] truncate text-muted-foreground/70">
313
+ {savedDetail}
314
+ </span>
315
+ )}
316
+ <Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
317
+ </button>
318
+ ) : (
319
+ <div className="flex items-center gap-1.5">
320
+ {/* Inline thumb buttons */}
321
+ <button
322
+ type="button"
323
+ onClick={() => handleThumbClick("up")}
324
+ className={cn(
325
+ "p-1 rounded transition-colors",
326
+ thumbState === "up"
327
+ ? "text-foreground bg-muted"
328
+ : "text-muted-foreground/40 hover:text-foreground hover:bg-muted/50",
329
+ )}
330
+ title="This factor is accurate"
331
+ data-testid={`factor-thumb-up-${factor.key}`}
332
+ >
333
+ <ThumbsUp className="h-[10px] w-[10px]" />
334
+ </button>
335
+ <button
336
+ type="button"
337
+ onClick={() => handleThumbClick("down")}
338
+ className={cn(
339
+ "p-1 rounded transition-colors",
340
+ thumbState === "down"
341
+ ? "text-red-600 bg-red-50"
342
+ : "text-muted-foreground/40 hover:text-red-600 hover:bg-red-50/50",
343
+ )}
344
+ title="Report issue with this factor"
345
+ data-testid={`factor-thumb-down-${factor.key}`}
346
+ >
347
+ <ThumbsDown className="h-[10px] w-[10px]" />
348
+ </button>
349
+
350
+ {/* Transient "Saved" pill */}
351
+ {saved && (
352
+ <span
353
+ className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
354
+ role="status"
355
+ data-testid={`factor-saved-${factor.key}`}
356
+ >
357
+ <Check className="h-[10px] w-[10px]" />
358
+ Saved
359
+ </span>
360
+ )}
361
+ </div>
362
+ )}
363
+
364
+ {/* Inline detail input */}
365
+ {showInput && thumbState && (
366
+ <div className="mt-1.5">
367
+ <input
368
+ type="text"
369
+ value={detailText}
370
+ onChange={(e) => setDetailText(e.target.value)}
371
+ onKeyDown={(e) => {
372
+ if (e.key === "Enter") handleSubmitDetail()
373
+ if (e.key === "Escape") setShowInput(false)
374
+ }}
375
+ placeholder={
376
+ thumbState === "up"
377
+ ? "What\u2019s accurate? (optional)"
378
+ : "What\u2019s wrong? (optional)"
379
+ }
380
+ className="w-full h-6 rounded border border-border bg-background px-2 text-[11px] text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
381
+ data-testid={`factor-detail-input-${factor.key}`}
382
+ />
383
+ <div className="mt-1 flex items-center gap-1.5">
384
+ <button
385
+ type="button"
386
+ onClick={handleSubmitDetail}
387
+ className="bg-foreground text-background rounded px-2 py-0.5 text-[10px] font-semibold"
388
+ data-testid={`factor-submit-${factor.key}`}
389
+ >
390
+ Submit
391
+ </button>
392
+ <button
393
+ type="button"
394
+ onClick={() => setShowInput(false)}
395
+ className="border border-border rounded px-2 py-0.5 text-[10px] font-medium"
396
+ >
397
+ Cancel
398
+ </button>
399
+ </div>
400
+ </div>
401
+ )}
402
+ </div>
403
+ </>
404
+ )}
227
405
  </div>
228
406
  )
229
407
  }
@@ -241,6 +419,9 @@ export function SignalPriorityPopover({
241
419
  feedbackChips,
242
420
  onFeedbackSubmit,
243
421
  className,
422
+ initialFactorFeedback,
423
+ onFactorFeedback,
424
+ initialPriorityFeedback,
244
425
  }: SignalPriorityPopoverProps) {
245
426
  const urgencyLabel = getSignalScoreUrgencyLabel(score, providedLabel)
246
427
  const scoreRange = scoreRangeForUrgency(urgencyLabel)
@@ -252,6 +433,9 @@ export function SignalPriorityPopover({
252
433
  const triggerHover = URGENCY_TRIGGER_HOVER[urgencyLabel]
253
434
  const triggerOpen = URGENCY_TRIGGER_OPEN[urgencyLabel]
254
435
 
436
+ // Derive a stable feedbackKey for the footer from score + urgencyLabel
437
+ const footerFeedbackKey = `priority-${score}-${urgencyLabel}`
438
+
255
439
  return (
256
440
  <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
257
441
  <PopoverPrimitive.Trigger asChild>
@@ -332,7 +516,12 @@ export function SignalPriorityPopover({
332
516
  {/* Factor rows */}
333
517
  <div className="divide-y divide-border/40">
334
518
  {factors.map((factor) => (
335
- <PriorityFactorRow key={factor.key} factor={factor} />
519
+ <PriorityFactorRow
520
+ key={factor.key}
521
+ factor={factor}
522
+ initialFeedback={initialFactorFeedback?.[factor.key]}
523
+ onFactorFeedback={onFactorFeedback}
524
+ />
336
525
  ))}
337
526
  </div>
338
527
  </>
@@ -349,6 +538,8 @@ export function SignalPriorityPopover({
349
538
  negativeChips={feedbackChips ?? DEFAULT_PRIORITY_FEEDBACK_CHIPS}
350
539
  positivePrompt="Thanks. Anything to keep about this score?"
351
540
  className="px-4 py-3"
541
+ initialFeedback={initialPriorityFeedback}
542
+ feedbackKey={footerFeedbackKey}
352
543
  />
353
544
  </div>
354
545
  )}
package/src/index.ts CHANGED
@@ -38,7 +38,7 @@ export * from "./components/dropdown-menu"
38
38
  export * from "./components/empty-state"
39
39
  export * from "./components/entity-panel"
40
40
  export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions } from "./components/feedback-primitives"
41
- export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData } from "./components/feedback-primitives"
41
+ export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData, PersistedFeedbackData } from "./components/feedback-primitives"
42
42
  export { SignalPriorityPopover } from "./components/signal-priority-popover"
43
43
  export type { SignalPriorityPopoverProps, PriorityFactor } from "./components/signal-priority-popover"
44
44
  export * from "./components/filter-chip"
@@ -3,41 +3,83 @@ import { describe, expect, it } from "vitest"
3
3
  import { displayName, getInitials, shortName } from "../user-display"
4
4
 
5
5
  describe("displayName", () => {
6
- it("prefers first and last name", () => {
6
+ it("returns first_name + last_name when both are present", () => {
7
7
  expect(
8
- displayName({ first_name: "Sarah", last_name: "Mitchell", name: "S Mitchell", email: "sarah@example.com" }),
8
+ displayName({ first_name: "Sarah", last_name: "Mitchell", name: "S Mitchell", email: "sarah@example.com" })
9
9
  ).toBe("Sarah Mitchell")
10
10
  })
11
11
 
12
- it("falls back through first name, name, email local part, then unknown", () => {
13
- expect(displayName({ first_name: "Sarah", last_name: null, name: null, email: "sarah@example.com" })).toBe("Sarah")
14
- expect(displayName({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe("Sarah Mitchell")
12
+ it("returns first_name alone when last_name is missing", () => {
13
+ expect(displayName({ first_name: "Sarah", last_name: null, name: null, email: "sarah@example.com" })).toBe(
14
+ "Sarah"
15
+ )
16
+ })
17
+
18
+ it("falls back to name when first_name is missing", () => {
19
+ expect(displayName({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe(
20
+ "Sarah Mitchell"
21
+ )
22
+ })
23
+
24
+ it("falls back to email local-part when name fields are missing or empty", () => {
15
25
  expect(displayName({ first_name: "", last_name: "", name: "", email: "sarah@example.com" })).toBe("sarah")
26
+ })
27
+
28
+ it("falls back to Unknown user when profile fields are absent", () => {
16
29
  expect(displayName({})).toBe("Unknown user")
17
30
  expect(displayName()).toBe("Unknown user")
18
31
  })
32
+
33
+ it("ignores last_name if first_name is missing", () => {
34
+ expect(displayName({ first_name: null, last_name: "Mitchell", name: "Old Name", email: "sarah@example.com" })).toBe(
35
+ "Old Name"
36
+ )
37
+ })
19
38
  })
20
39
 
21
40
  describe("getInitials", () => {
22
- it("uses first and last initials", () => {
41
+ it("returns uppercased initials from first_name and last_name", () => {
23
42
  expect(getInitials({ first_name: "joe", last_name: "kim", email: "joe@example.com" })).toBe("JK")
24
43
  })
25
44
 
26
- it("falls back through name, email local part, then question mark", () => {
27
- expect(getInitials({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe("SM")
45
+ it("splits name into two parts for initials when first/last are not set", () => {
46
+ expect(getInitials({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe(
47
+ "SM"
48
+ )
49
+ })
50
+
51
+ it("uses first two characters of a single-word name", () => {
28
52
  expect(getInitials({ first_name: null, last_name: null, name: "Sarah", email: "sarah@example.com" })).toBe("SA")
53
+ })
54
+
55
+ it("falls back to first two email local characters when no name fields are set", () => {
29
56
  expect(getInitials({ first_name: null, last_name: null, name: null, email: "zz@example.com" })).toBe("ZZ")
57
+ })
58
+
59
+ it("returns question mark when no initials can be derived", () => {
30
60
  expect(getInitials({})).toBe("?")
31
61
  expect(getInitials()).toBe("?")
32
62
  })
63
+
64
+ it("handles multi-word name taking the first two initials", () => {
65
+ expect(getInitials({ name: "Mary Jane Watson", email: "mj@example.com" })).toBe("MJ")
66
+ })
33
67
  })
34
68
 
35
69
  describe("shortName", () => {
36
- it("returns compact first name and last initial", () => {
70
+ it("returns 'First L.' when first and last are present", () => {
37
71
  expect(shortName({ first_name: "Sarah", last_name: "Mitchell", email: "sarah@example.com" })).toBe("Sarah M.")
38
72
  })
39
73
 
40
- it("falls back to displayName when last name is unavailable", () => {
41
- expect(shortName({ name: "Sarah Mitchell", email: "sarah@example.com" })).toBe("Sarah Mitchell")
74
+ it("falls back to displayName when last_name is unavailable", () => {
75
+ expect(shortName({ first_name: "Sarah", last_name: null, name: null, email: "sarah@example.com" })).toBe("Sarah")
76
+ })
77
+
78
+ it("falls back to email local-part when all names are missing", () => {
79
+ expect(shortName({ first_name: null, last_name: null, name: null, email: "sarah@example.com" })).toBe("sarah")
80
+ })
81
+
82
+ it("falls back to Unknown user when no profile fields are present", () => {
83
+ expect(shortName({})).toBe("Unknown user")
42
84
  })
43
85
  })
@@ -16,7 +16,7 @@ import type { TimelineEvent } from "../components/timeline-activity"
16
16
  import type { ApprovalState } from "../components/signal-feedback-inline"
17
17
  import type { LucideIcon } from "lucide-react"
18
18
  import type { PriorityFactor } from "../components/signal-priority-popover"
19
- import type { FeedbackChipTree, FeedbackSubmitData } from "../components/feedback-primitives"
19
+ import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "../components/feedback-primitives"
20
20
 
21
21
  // ---------------------------------------------------------------------------
22
22
  // Shared
@@ -57,6 +57,10 @@ export interface SignalScoreExplanationSignal {
57
57
  counterparty?: string
58
58
  /** Component breakdown for combined signals. */
59
59
  components?: Array<{ type: string; count: number }>
60
+ /** Current balance value (e.g., "$3.0M") for balance context strip. */
61
+ currentBalance?: string
62
+ /** Additional balance context text (e.g., "down from $23M"). */
63
+ balanceContext?: string
60
64
  }
61
65
 
62
66
  export interface SignalScoreExplanationBucket {
@@ -97,6 +101,12 @@ export interface SignalScoreData {
97
101
  /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
98
102
  initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
99
103
  initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
104
+ /** Factor-level feedback for the priority popover rows (keyed by factor key). */
105
+ initialFactorPopoverFeedback?: Record<string, { type: "up" | "down"; detail: string; ownershipLabel?: string }>
106
+ /** Persisted bucket-level feedback, keyed by bucket key. */
107
+ initialBucketFeedback?: Record<string, PersistedFeedbackData>
108
+ /** Persisted priority-level feedback for the popover footer. */
109
+ initialPriorityFeedback?: PersistedFeedbackData | null
100
110
  /** Priority factors for the popover breakdown. */
101
111
  priorityFactors?: PriorityFactor[]
102
112
  /** Negative feedback chip tree for the priority popover. */
@@ -272,6 +272,9 @@ export function DetailView({
272
272
  metaText={undefined}
273
273
  feedbackChips={signalData.priorityFeedbackChips}
274
274
  onFeedbackSubmit={signalData.onPriorityFeedback}
275
+ initialFactorFeedback={signalData.initialFactorPopoverFeedback}
276
+ onFactorFeedback={signalData.onFactorFeedback}
277
+ initialPriorityFeedback={signalData.initialPriorityFeedback}
275
278
  />
276
279
  {signalData.timeChipLabel && (
277
280
  <Badge variant="outline" title={signalData.timeChipDetail ?? undefined}>