@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
@@ -80,16 +80,12 @@ describe("DetailView corrected compact score WHY UX", () => {
80
80
 
81
81
  fireEvent.click(screen.getByRole("button", { name: /urgent priority/i }));
82
82
 
83
+ // The new popover shows urgency explanation and score info in a different format
83
84
  expect(screen.getByText("Customer activity spiked today.")).toBeInTheDocument();
84
- expect(screen.getByText("Score 82/100")).toBeInTheDocument();
85
- expect(screen.getByText("Urgent range: 80-100")).toBeInTheDocument();
86
- expect(screen.getByText("Why now")).toBeInTheDocument();
87
- const priorityPanel = screen.getByRole("region", { name: /priority explanation/i });
88
- expect(within(priorityPanel).getByText("Strong signals detected.")).toBeInTheDocument();
89
- expect(screen.getByText("Top factor")).toBeInTheDocument();
90
- expect(screen.getAllByText("Strong signal").length).toBeGreaterThan(0);
91
- expect(screen.getByText("Trigger strength")).toBeInTheDocument();
92
- expect(screen.getByText("Company fit")).toBeInTheDocument();
85
+ expect(screen.getByText(/82/)).toBeInTheDocument();
86
+ expect(screen.getByText(/\/100/)).toBeInTheDocument();
87
+ // Popover shows "Why this is urgent priority" heading
88
+ expect(screen.getByText(/why this is urgent priority/i)).toBeInTheDocument();
93
89
  expect(screen.queryByText("How's this score?")).toBeNull();
94
90
  });
95
91
 
@@ -116,21 +112,40 @@ describe("DetailView corrected compact score WHY UX", () => {
116
112
  expect(screen.getByRole("region", { name: /treasury activity details/i })).toBeInTheDocument();
117
113
  });
118
114
 
119
- it("resets selected bucket and priority panel when item id changes", () => {
120
- const { rerender } = render(<DetailView {...baseProps()} />);
115
+ it("resets selected bucket when item id changes", () => {
116
+ const { rerender } = render(
117
+ <DetailView
118
+ {...baseProps({
119
+ getSignalScore: () =>
120
+ makeSignalScore({
121
+ explanationBuckets: [
122
+ { key: "signal-a", label: "Treasury activity", kind: "signal", primarySignalId: "sig-1", signals: [{ id: "sig-1", label: "Treasury signal" }] },
123
+ ],
124
+ }),
125
+ })}
126
+ />,
127
+ );
121
128
 
122
- fireEvent.click(screen.getByRole("button", { name: /urgent priority/i }));
123
- expect(screen.getByRole("region", { name: /priority explanation/i })).toBeInTheDocument();
129
+ // Expand a WHY bucket
130
+ fireEvent.click(screen.getByRole("button", { name: /treasury activity/i }));
131
+ expect(screen.getByRole("region", { name: /treasury activity details/i })).toBeInTheDocument();
124
132
 
125
133
  rerender(
126
134
  <DetailView
127
135
  {...baseProps({
128
136
  item: { ...baseItem, id: "case-2", title: "Second Signal" },
137
+ getSignalScore: () =>
138
+ makeSignalScore({
139
+ explanationBuckets: [
140
+ { key: "signal-a", label: "Treasury activity", kind: "signal", primarySignalId: "sig-1", signals: [{ id: "sig-1", label: "Treasury signal" }] },
141
+ ],
142
+ }),
129
143
  })}
130
144
  />,
131
145
  );
132
146
 
133
- expect(screen.queryByRole("region", { name: /priority explanation/i })).toBeNull();
147
+ // Bucket should be collapsed after item change
148
+ expect(screen.queryByRole("region", { name: /treasury activity details/i })).toBeNull();
134
149
  });
135
150
 
136
151
  it("does not render factor-derived WHY chips when no signal buckets are present", () => {
@@ -167,7 +182,7 @@ describe("DetailView corrected compact score WHY UX", () => {
167
182
  expect(screen.queryByRole("button", { name: /company fit/i })).toBeNull();
168
183
  });
169
184
 
170
- it("exposes aria-expanded and aria-controls for priority and bucket triggers", () => {
185
+ it("exposes aria-expanded and aria-controls for bucket triggers", () => {
171
186
  render(
172
187
  <DetailView
173
188
  {...baseProps({
@@ -181,13 +196,6 @@ describe("DetailView corrected compact score WHY UX", () => {
181
196
  />,
182
197
  );
183
198
 
184
- const priorityButton = screen.getByRole("button", { name: /urgent priority/i });
185
- expect(priorityButton.getAttribute("aria-expanded")).toBe("false");
186
- expect(priorityButton.getAttribute("aria-controls")).toBeTruthy();
187
- fireEvent.click(priorityButton);
188
- expect(priorityButton.getAttribute("aria-expanded")).toBe("true");
189
- expect(document.getElementById(priorityButton.getAttribute("aria-controls")!)).toBeInTheDocument();
190
-
191
199
  const bucketButton = screen.getByRole("button", { name: /signal a/i });
192
200
  expect(bucketButton.getAttribute("aria-expanded")).toBe("false");
193
201
  expect(bucketButton.getAttribute("aria-controls")).toBeTruthy();
@@ -213,7 +221,8 @@ describe("DetailView corrected compact score WHY UX", () => {
213
221
  );
214
222
 
215
223
  expect(screen.getAllByRole("button", { name: /treasury activity/i })).toHaveLength(1);
216
- expect(screen.getByText("×3")).toBeInTheDocument();
224
+ // Task 3 redesigned WHY pills: count badge now uses "x{N}" format
225
+ expect(screen.getByText(/x\s*3/)).toBeInTheDocument();
217
226
 
218
227
  fireEvent.click(screen.getByRole("button", { name: /treasury activity/i }));
219
228
  const matchingSignals = screen.getByRole("list", { name: /matching signals/i });
@@ -297,7 +306,7 @@ describe("DetailView corrected compact score WHY UX", () => {
297
306
  });
298
307
  });
299
308
 
300
- it("shows factor feedback in the priority panel, not signal WHY panels", () => {
309
+ it("signal WHY panels do not render factor feedback UI", () => {
301
310
  const onFactorFeedback = vi.fn();
302
311
  render(
303
312
  <DetailView
@@ -317,10 +326,5 @@ describe("DetailView corrected compact score WHY UX", () => {
317
326
  fireEvent.click(screen.getByRole("button", { name: /signal only/i }));
318
327
  const signalPanel = screen.getByRole("region", { name: /signal only details/i });
319
328
  expect(within(signalPanel).queryByTitle("This factor is accurate")).toBeNull();
320
-
321
- fireEvent.click(screen.getByRole("button", { name: /urgent priority/i }));
322
- const priorityPanel = screen.getByRole("region", { name: /priority explanation/i });
323
- expect(within(priorityPanel).getAllByTitle("This factor is accurate").length).toBeGreaterThan(0);
324
- expect(within(priorityPanel).getByText("Needs review")).toBeInTheDocument();
325
329
  });
326
330
  });
@@ -0,0 +1,65 @@
1
+ import { render, screen } from "@testing-library/react"
2
+ import { describe, expect, it, vi } from "vitest"
3
+
4
+ import { DetailView } from "../prototype-inbox-view"
5
+ import type { DetailViewProps } from "../prototype-inbox-view"
6
+
7
+ const baseItem = {
8
+ id: "case-1",
9
+ title: "Churn risk case title",
10
+ details: "Case details",
11
+ statusColor: "red",
12
+ time: "1h ago",
13
+ company: "Acme Corp",
14
+ tag1: "Signal",
15
+ }
16
+
17
+ function renderDetailView(overrides: Partial<DetailViewProps> = {}) {
18
+ const props: DetailViewProps = {
19
+ item: baseItem,
20
+ sections: { signalBrief: false, suggestedActions: false, timeline: false },
21
+ getSignalScore: () => ({
22
+ score: 72,
23
+ factors: [],
24
+ whyNow: "Why now",
25
+ evidence: [],
26
+ confidence: 80,
27
+ urgencyLabel: "High",
28
+ urgencyExplanation: "High priority",
29
+ }),
30
+ buildSuggestedActions: () => [],
31
+ buildSourceItems: () => [],
32
+ accountContacts: [],
33
+ emailSignature: "",
34
+ iconMap: {},
35
+ ...overrides,
36
+ }
37
+
38
+ return render(<DetailView {...props} />)
39
+ }
40
+
41
+ describe("DetailView title slots", () => {
42
+ it("renders supporting title text below the main title", () => {
43
+ renderDetailView({
44
+ renderTitleSubtext: (item) => <p data-testid="title-subtext">Full title: {item.title}</p>,
45
+ })
46
+
47
+ expect(screen.getByTestId("title-subtext").textContent).toBe(
48
+ "Full title: Churn risk case title",
49
+ )
50
+ })
51
+
52
+ it("renders title extra content beside the main title", () => {
53
+ const onClick = vi.fn()
54
+
55
+ renderDetailView({
56
+ renderTitleExtra: () => (
57
+ <button type="button" onClick={onClick}>
58
+ Quick action
59
+ </button>
60
+ ),
61
+ })
62
+
63
+ expect(screen.getByRole("button", { name: "Quick action" })).toBeTruthy()
64
+ })
65
+ })
@@ -15,6 +15,8 @@ import type {
15
15
  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
+ import type { PriorityFactor } from "../components/signal-priority-popover"
19
+ import type { FeedbackChipTree, FeedbackSubmitData } from "../components/feedback-primitives"
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
22
  // Shared
@@ -45,6 +47,16 @@ export interface SignalScoreExplanationSignal {
45
47
  source?: string
46
48
  time?: string
47
49
  metric?: string
50
+ /** Signal type name (e.g., "treasury_liquidation"). Used for combined signal component identification. */
51
+ signalTypeName?: string
52
+ /** Primary display value (e.g., "-$1,724,310.11"). */
53
+ primaryValue?: string
54
+ /** Qualifier text (e.g., "100% of balance"). */
55
+ qualifier?: string
56
+ /** Counterparty / destination (e.g., "-> JPMorgan Chase --6042"). */
57
+ counterparty?: string
58
+ /** Component breakdown for combined signals. */
59
+ components?: Array<{ type: string; count: number }>
48
60
  }
49
61
 
50
62
  export interface SignalScoreExplanationBucket {
@@ -62,6 +74,10 @@ export interface SignalScoreExplanationBucket {
62
74
  signalIds?: string[]
63
75
  primarySignalId?: string
64
76
  factorKeys?: string[]
77
+ /** Lucide icon name for the bucket type (e.g., "trending-down"). */
78
+ icon?: string
79
+ /** Tonal styling hint for the bucket. */
80
+ tone?: "alert" | "warn" | "info"
65
81
  }
66
82
 
67
83
  export interface SignalScoreData {
@@ -81,6 +97,14 @@ export interface SignalScoreData {
81
97
  /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
82
98
  initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
83
99
  initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
100
+ /** Priority factors for the popover breakdown. */
101
+ priorityFactors?: PriorityFactor[]
102
+ /** Negative feedback chip tree for the priority popover. */
103
+ priorityFeedbackChips?: FeedbackChipTree[]
104
+ /** Callback when user submits priority-level feedback. */
105
+ onPriorityFeedback?: (data: FeedbackSubmitData) => void
106
+ /** Callback when user submits bucket-level feedback. */
107
+ onBucketFeedback?: (bucketKey: string, data: FeedbackSubmitData) => void
84
108
  /** AI-generated signal brief text. When present, rendered in a dedicated section. */
85
109
  signalBrief?: string
86
110
  /** Compact label for time-remaining chip (e.g., "13 days left"). */
@@ -172,6 +196,10 @@ export interface InboxViewConfig {
172
196
  renderAfterScore?: (item: QueueItem) => React.ReactNode
173
197
  /** Formatted string for "Last activity X ago" in the collapsed timeline header. If omitted, falls back to the first event's time. */
174
198
  lastActivityTime?: string
199
+ /** Render extra content inline with the detail title. */
200
+ renderTitleExtra?: (item: QueueItem) => React.ReactNode
201
+ /** Render supporting content below the detail title. */
202
+ renderTitleSubtext?: (item: QueueItem) => React.ReactNode
175
203
  /** Sort options for the inbox. When provided, a sort dropdown is rendered in the split view toolbar. */
176
204
  sortOptions?: InboxSortOption[]
177
205
  /** Currently active sort option id. */
@@ -38,7 +38,8 @@ import {
38
38
  } from "../components/inbox-toolbar"
39
39
  import { GroupedListView, type GroupedListGroup } from "../components/item-list"
40
40
  import { SignalApproval, type ApprovalState, type OpportunityPreview } from "../components/signal-feedback-inline"
41
- import { ScoreWhyChips, SignalPriorityChip, SignalPriorityPanel } from "../components/score-why-chips"
41
+ import { ScoreWhyChips } from "../components/score-why-chips"
42
+ import { SignalPriorityPopover } from "../components/signal-priority-popover"
42
43
  import { type SourceDef } from "../components/detail-view"
43
44
  import {
44
45
  SuggestedActions,
@@ -137,6 +138,10 @@ export interface DetailViewProps {
137
138
  /** Render content between the signal score section and the activity timeline. */
138
139
  renderAfterScore?: (item: QueueItem) => React.ReactNode
139
140
  lastActivityTime?: string
141
+ /** Render extra content inline with the detail title. */
142
+ renderTitleExtra?: (item: QueueItem) => React.ReactNode
143
+ /** Render supporting content below the detail title. */
144
+ renderTitleSubtext?: (item: QueueItem) => React.ReactNode
140
145
  /** Render extra metadata chips (e.g. assignee) inside the chips row below the title. */
141
146
  renderMetadataExtra?: (item: QueueItem) => React.ReactNode
142
147
  onOpenSignalBucket?: (args: { item: QueueItem; bucketKey: string; signalId: string }) => void
@@ -171,6 +176,8 @@ export function DetailView({
171
176
  renderBeforeScore,
172
177
  renderAfterScore,
173
178
  lastActivityTime,
179
+ renderTitleExtra,
180
+ renderTitleSubtext,
174
181
  renderMetadataExtra,
175
182
  onOpenSignalBucket,
176
183
  approveButtonIconUrl,
@@ -180,13 +187,10 @@ export function DetailView({
180
187
  }: DetailViewProps) {
181
188
  const [showTimeline, setShowTimeline] = React.useState(false)
182
189
  const [extraActions, setExtraActions] = React.useState<SuggestedAction[]>([])
183
- const [priorityOpen, setPriorityOpen] = React.useState(false)
184
- const priorityPanelId = React.useId()
185
190
 
186
191
  React.useEffect(() => {
187
192
  setShowTimeline(false)
188
193
  setExtraActions([])
189
- setPriorityOpen(false)
190
194
  }, [item.id])
191
195
 
192
196
  const signalData = React.useMemo(
@@ -251,15 +255,23 @@ export function DetailView({
251
255
  <span className="text-xs text-muted-foreground">{item.company}</span>
252
256
  </div>
253
257
 
254
- <h1 className="mb-3 text-2xl font-bold tracking-tight text-foreground">{item.title}</h1>
258
+ <div className="mb-3 flex flex-wrap items-start gap-x-3 gap-y-2">
259
+ <div className="min-w-0 flex-1">
260
+ <h1 className="text-2xl font-bold tracking-tight text-foreground">{item.title}</h1>
261
+ {renderTitleSubtext?.(item)}
262
+ </div>
263
+ {renderTitleExtra?.(item)}
264
+ </div>
255
265
 
256
266
  <div className="mb-6 flex flex-wrap items-center gap-2">
257
- <SignalPriorityChip
267
+ <SignalPriorityPopover
258
268
  score={signalData.score}
259
269
  urgencyLabel={signalData.urgencyLabel}
260
- isOpen={priorityOpen}
261
- controlsId={priorityPanelId}
262
- onClick={() => setPriorityOpen((prev) => !prev)}
270
+ urgencyExplanation={signalData.urgencyExplanation ?? signalData.signalBrief}
271
+ factors={signalData.priorityFactors ?? []}
272
+ metaText={undefined}
273
+ feedbackChips={signalData.priorityFeedbackChips}
274
+ onFeedbackSubmit={signalData.onPriorityFeedback}
263
275
  />
264
276
  {signalData.timeChipLabel && (
265
277
  <Badge variant="outline" title={signalData.timeChipDetail ?? undefined}>
@@ -280,8 +292,6 @@ export function DetailView({
280
292
  {renderMetadataExtra?.(item)}
281
293
  </div>
282
294
 
283
- {priorityOpen && <SignalPriorityPanel id={priorityPanelId} signalData={signalData} className="mb-6" />}
284
-
285
295
  {/* Signal Brief */}
286
296
  {sections.signalBrief && (() => {
287
297
  const briefHeading = signalBriefCopy?.heading ?? "Signal brief"
@@ -440,6 +450,8 @@ export function PrototypeInboxView({
440
450
  renderBeforeScore,
441
451
  renderAfterScore,
442
452
  lastActivityTime,
453
+ renderTitleExtra,
454
+ renderTitleSubtext,
443
455
  sortOptions,
444
456
  activeSortId,
445
457
  onSortChange,
@@ -678,6 +690,8 @@ export function PrototypeInboxView({
678
690
  renderBeforeScore,
679
691
  renderAfterScore,
680
692
  lastActivityTime,
693
+ renderTitleExtra,
694
+ renderTitleSubtext,
681
695
  onOpenSignalBucket,
682
696
  }
683
697