@handled-ai/design-system 0.18.3 → 0.18.4

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 (38) hide show
  1. package/dist/charts/chart.d.ts +1 -1
  2. package/dist/components/feedback-primitives.d.ts +41 -2
  3. package/dist/components/feedback-primitives.js +241 -6
  4. package/dist/components/feedback-primitives.js.map +1 -1
  5. package/dist/components/score-why-chips.d.ts +1 -1
  6. package/dist/components/score-why-chips.js +26 -5
  7. package/dist/components/score-why-chips.js.map +1 -1
  8. package/dist/components/signal-priority-popover.d.ts +1 -1
  9. package/dist/components/signal-priority-popover.js +32 -6
  10. package/dist/components/signal-priority-popover.js.map +1 -1
  11. package/dist/components/timeline-activity.d.ts +1 -16
  12. package/dist/components/timeline-activity.js +1 -69
  13. package/dist/components/timeline-activity.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/prototype/index.d.ts +1 -1
  18. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  19. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  20. package/dist/prototype/prototype-config.d.ts +1 -1
  21. package/dist/prototype/prototype-inbox-view.d.ts +2 -12
  22. package/dist/prototype/prototype-inbox-view.js +37 -102
  23. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  24. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  25. package/dist/prototype/prototype-shell.d.ts +1 -1
  26. package/dist/{signal-priority-popover-DQ_VuHac.d.ts → signal-priority-popover-DWaAMhPI.d.ts} +26 -2
  27. package/package.json +3 -1
  28. package/src/components/__tests__/wit-636-feedback-states.test.tsx +546 -0
  29. package/src/components/feedback-primitives.tsx +333 -26
  30. package/src/components/score-why-chips.tsx +28 -2
  31. package/src/components/signal-priority-popover.tsx +44 -4
  32. package/src/components/timeline-activity.tsx +1 -112
  33. package/src/index.ts +2 -2
  34. package/src/prototype/__tests__/detail-view-attention.test.tsx +2 -2
  35. package/src/prototype/prototype-config.ts +11 -1
  36. package/src/prototype/prototype-inbox-view.tsx +33 -131
  37. package/src/components/__tests__/timeline-activity.test.tsx +0 -137
  38. package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +0 -322
@@ -4,24 +4,6 @@ import * as React from "react"
4
4
  import { cn } from "../lib/utils"
5
5
  import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
6
6
 
7
- export type TimelineEventTone =
8
- | "red"
9
- | "amber"
10
- | "emerald"
11
- | "violet"
12
- | "blue"
13
- | "slate"
14
- | "salesforce"
15
- | "gmail"
16
-
17
- export interface TimelineEventActor {
18
- kind: "user" | "integration" | "system"
19
- name?: string
20
- initials?: string
21
- avatarUrl?: string
22
- verb?: string
23
- }
24
-
25
7
  export interface TimelineEvent {
26
8
  id: string
27
9
  icon: React.ReactNode
@@ -46,57 +28,8 @@ export interface TimelineEvent {
46
28
  defaultExpanded?: boolean
47
29
  isInteractive?: boolean
48
30
  onSourceClick?: () => void
49
- tone?: TimelineEventTone
50
- actor?: TimelineEventActor
51
- isSystemNoise?: boolean
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // Tone class map — every class is a complete static string literal so
56
- // Tailwind's JIT scanner can detect them. NO interpolation.
57
- // ---------------------------------------------------------------------------
58
-
59
- export const TONE_CLASSES: Record<
60
- TimelineEventTone,
61
- { dot: string; icon: string }
62
- > = {
63
- red: {
64
- dot: "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-900/40",
65
- icon: "text-red-600 dark:text-red-300",
66
- },
67
- amber: {
68
- dot: "bg-amber-50 border-amber-200 dark:bg-amber-950/30 dark:border-amber-900/40",
69
- icon: "text-amber-600 dark:text-amber-300",
70
- },
71
- emerald: {
72
- dot: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900/40",
73
- icon: "text-emerald-600 dark:text-emerald-300",
74
- },
75
- violet: {
76
- dot: "bg-violet-50 border-violet-200 dark:bg-violet-950/30 dark:border-violet-900/40",
77
- icon: "text-violet-600 dark:text-violet-300",
78
- },
79
- blue: {
80
- dot: "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-900/40",
81
- icon: "text-blue-600 dark:text-blue-300",
82
- },
83
- slate: {
84
- dot: "bg-slate-100 border-slate-200 dark:bg-slate-800/50 dark:border-slate-700",
85
- icon: "text-slate-500 dark:text-slate-300",
86
- },
87
- salesforce: {
88
- dot: "bg-white border-[#00A1E0]/25 dark:bg-background dark:border-[#00A1E0]/25",
89
- icon: "text-[#00A1E0]",
90
- },
91
- gmail: {
92
- dot: "bg-white border-red-200 dark:bg-background dark:border-red-900/40",
93
- icon: "text-red-500 dark:text-red-300",
94
- },
95
31
  }
96
32
 
97
- const NEUTRAL_DOT_CLASSES = "border-border/60 bg-background"
98
- const NEUTRAL_ICON_CLASSES = "text-muted-foreground"
99
-
100
33
  export interface TimelineActivityProps {
101
34
  events: TimelineEvent[]
102
35
  className?: string
@@ -116,54 +49,12 @@ export function TimelineActivity({ events, className }: TimelineActivityProps) {
116
49
  )
117
50
  }
118
51
 
119
- function ActorByline({ actor, time }: { actor: TimelineEventActor; time: string }) {
120
- if (actor.kind === "system") return null
121
-
122
- if (actor.kind === "integration") {
123
- return (
124
- <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="actor-byline">
125
- <span>Integration</span>
126
- <span className="text-muted-foreground/40">&middot;</span>
127
- <span>{time}</span>
128
- </div>
129
- )
130
- }
131
-
132
- // actor.kind === "user"
133
- const verb = actor.verb ?? "performed this action"
134
- const displayInitials = actor.initials ?? (actor.name ? actor.name.charAt(0).toUpperCase() : "?")
135
-
136
- return (
137
- <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="actor-byline">
138
- {actor.avatarUrl ? (
139
- <img
140
- src={actor.avatarUrl}
141
- alt={actor.name ?? "User"}
142
- className="h-4 w-4 rounded-full object-cover"
143
- />
144
- ) : (
145
- <span className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/10 text-[8px] font-semibold text-muted-foreground">
146
- {displayInitials}
147
- </span>
148
- )}
149
- <span className="text-foreground font-medium">{actor.name}</span>
150
- <span>{verb}</span>
151
- <span className="text-muted-foreground/40">&middot;</span>
152
- <span>{time}</span>
153
- </div>
154
- )
155
- }
156
-
157
52
  function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean }) {
158
53
  const [expanded, setExpanded] = React.useState(event.defaultExpanded ?? false)
159
54
  const [showAllRecipients, setShowAllRecipients] = React.useState(false)
160
55
  const hasContent = !!event.content
161
56
  const hasEmail = !!event.email
162
57
 
163
- const toneStyle = event.tone ? TONE_CLASSES[event.tone] : null
164
- const dotClasses = toneStyle ? toneStyle.dot : NEUTRAL_DOT_CLASSES
165
- const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
166
-
167
58
  return (
168
59
  <div className="group relative flex gap-3.5">
169
60
  {!isLast && (
@@ -171,7 +62,7 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
171
62
  )}
172
63
 
173
64
  <div className="relative z-10 mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-background">
174
- <div className={cn("flex h-4.5 w-4.5 items-center justify-center rounded-full border ring-4 ring-background", dotClasses, iconClasses)} data-testid="timeline-dot">
65
+ <div className="flex h-4.5 w-4.5 items-center justify-center rounded-full border border-border/60 bg-background text-muted-foreground ring-4 ring-background">
175
66
  {event.icon}
176
67
  </div>
177
68
  </div>
@@ -186,8 +77,6 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
186
77
  </span>
187
78
  </div>
188
79
 
189
- {event.actor && <ActorByline actor={event.actor} time={event.time} />}
190
-
191
80
  {(hasContent || hasEmail) && (
192
81
  <div className="mt-2">
193
82
  {event.isInteractive ? (
package/src/index.ts CHANGED
@@ -37,8 +37,8 @@ export * from "./components/dialog"
37
37
  export * from "./components/dropdown-menu"
38
38
  export * from "./components/empty-state"
39
39
  export * from "./components/entity-panel"
40
- export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions } from "./components/feedback-primitives"
41
- export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData } from "./components/feedback-primitives"
40
+ export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions, InlineFeedbackControl } from "./components/feedback-primitives"
41
+ export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData, PersistedFeedbackData, InlineFeedbackControlProps } 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"
@@ -89,8 +89,8 @@ describe("DetailView attentionCount", () => {
89
89
  expect(pill).not.toBeNull();
90
90
  expect(pill!.textContent).toContain("5");
91
91
 
92
- // Click the timeline collapse button to expand
93
- const timelineButton = container.querySelector('[data-testid="timeline-collapse-btn"]') as HTMLElement;
92
+ // Click the timeline header button to expand
93
+ const timelineButton = container.querySelector("button.group\\/timeline") as HTMLElement;
94
94
  expect(timelineButton).not.toBeNull();
95
95
  fireEvent.click(timelineButton);
96
96
 
@@ -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. */
@@ -150,16 +150,6 @@ export interface DetailViewProps {
150
150
  onRequestApproval?: () => Promise<void>
151
151
  /** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
152
152
  attentionCount?: number
153
- /** Label for the system-events toggle button (e.g. "Score changes"). */
154
- timelineSystemEventsToggleLabel?: string
155
- /** localStorage key for persisting the system-events toggle state. */
156
- timelineSystemEventsStorageKey?: string
157
- /** Whether system-noise events are visible by default. @default false */
158
- timelineSystemEventsDefaultVisible?: boolean
159
- /** Hint text shown below the timeline when system events are hidden. */
160
- timelineSystemEventsHiddenHint?: string
161
- /** Hint text shown below the timeline when system events are visible. Uses {count} as placeholder. */
162
- timelineSystemEventsVisibleHint?: string
163
153
  }
164
154
 
165
155
  export function DetailView({
@@ -194,47 +184,10 @@ export function DetailView({
194
184
  opportunityPreview,
195
185
  onRequestApproval,
196
186
  attentionCount,
197
- timelineSystemEventsToggleLabel,
198
- timelineSystemEventsStorageKey,
199
- timelineSystemEventsDefaultVisible = false,
200
- timelineSystemEventsHiddenHint,
201
- timelineSystemEventsVisibleHint,
202
187
  }: DetailViewProps) {
203
188
  const [showTimeline, setShowTimeline] = React.useState(false)
204
189
  const [extraActions, setExtraActions] = React.useState<SuggestedAction[]>([])
205
190
 
206
- // ---- System-noise toggle state ----
207
- const [showSystemEvents, setShowSystemEvents] = React.useState(timelineSystemEventsDefaultVisible)
208
- const initialReadDoneRef = React.useRef(false)
209
-
210
- // Read persisted value from localStorage on mount
211
- React.useEffect(() => {
212
- if (!timelineSystemEventsStorageKey) {
213
- initialReadDoneRef.current = true
214
- return
215
- }
216
- try {
217
- const stored = localStorage.getItem(timelineSystemEventsStorageKey)
218
- if (stored !== null) {
219
- setShowSystemEvents(stored === "true")
220
- }
221
- } catch {
222
- // localStorage unavailable — ignore
223
- }
224
- initialReadDoneRef.current = true
225
- }, [timelineSystemEventsStorageKey])
226
-
227
- // Write to localStorage when the toggle changes (skip initial if matching default)
228
- React.useEffect(() => {
229
- if (!timelineSystemEventsStorageKey) return
230
- if (!initialReadDoneRef.current) return
231
- try {
232
- localStorage.setItem(timelineSystemEventsStorageKey, String(showSystemEvents))
233
- } catch {
234
- // localStorage unavailable — ignore
235
- }
236
- }, [showSystemEvents, timelineSystemEventsStorageKey])
237
-
238
191
  React.useEffect(() => {
239
192
  setShowTimeline(false)
240
193
  setExtraActions([])
@@ -319,6 +272,9 @@ export function DetailView({
319
272
  metaText={undefined}
320
273
  feedbackChips={signalData.priorityFeedbackChips}
321
274
  onFeedbackSubmit={signalData.onPriorityFeedback}
275
+ initialFactorFeedback={signalData.initialFactorPopoverFeedback}
276
+ onFactorFeedback={signalData.onFactorFeedback}
277
+ initialPriorityFeedback={signalData.initialPriorityFeedback}
322
278
  />
323
279
  {signalData.timeChipLabel && (
324
280
  <Badge variant="outline" title={signalData.timeChipDetail ?? undefined}>
@@ -398,92 +354,38 @@ export function DetailView({
398
354
  {renderAfterScore?.(item)}
399
355
 
400
356
  {/* Activity Timeline */}
401
- {sections.timeline && timelineEvents.length > 0 && (() => {
402
- const visibleEvents = timelineEvents.filter(
403
- (e) => !e.isSystemNoise || showSystemEvents,
404
- )
405
- const hiddenCount = timelineEvents.filter((e) => e.isSystemNoise).length
406
- const hasSystemNoise = hiddenCount > 0
407
- const showToggle =
408
- hasSystemNoise &&
409
- !!(timelineSystemEventsToggleLabel || timelineSystemEventsStorageKey)
410
- const firstVisibleTime =
411
- lastActivityTime ?? (visibleEvents.length > 0 ? visibleEvents[0].time : "")
412
- const visibleCount = visibleEvents.length
413
- const eventCountLabel = `${visibleCount} ${visibleCount === 1 ? "event" : "events"}`
414
-
415
- return (
416
- <div className="mb-8">
417
- {/* Header outer non-interactive container */}
418
- <div
419
- className="group/timeline flex w-full items-center justify-between gap-2 py-2 rounded-md transition-colors hover:bg-muted/40 -mx-2 px-2"
420
- data-testid="timeline-header"
421
- >
422
- {/* Left: collapse/expand button */}
423
- <button
424
- type="button"
425
- onClick={() => setShowTimeline((prev) => !prev)}
426
- className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0"
427
- data-testid="timeline-collapse-btn"
428
- >
429
- <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
430
- {!showTimeline && attentionCount != null && attentionCount > 0 && (
431
- <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
432
- {attentionCount} new
433
- </span>
434
- )}
435
- {!showTimeline && firstVisibleTime && (
436
- <span className="text-[11px] text-muted-foreground/60" data-testid="last-activity-hint">
437
- &middot; Last activity {firstVisibleTime}
438
- </span>
439
- )}
440
- <div className="flex items-center gap-1.5">
441
- <span className="text-[11px] font-medium text-muted-foreground" data-testid="event-count">{eventCountLabel}</span>
442
- <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
443
- </div>
444
- </button>
445
-
446
- {/* Right: system-events toggle */}
447
- {showToggle && (
448
- <button
449
- type="button"
450
- onClick={() => setShowSystemEvents((prev) => !prev)}
451
- className="flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground cursor-pointer"
452
- aria-pressed={showSystemEvents}
453
- data-testid="system-events-toggle"
454
- >
455
- {timelineSystemEventsToggleLabel ?? "System events"}
456
- <span
457
- className="inline-flex items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold min-w-[18px] tabular-nums"
458
- data-testid="hidden-count-badge"
459
- >
460
- {hiddenCount}
461
- </span>
462
- </button>
357
+ {sections.timeline && timelineEvents.length > 0 && (
358
+ <div className="mb-8">
359
+ <button
360
+ type="button"
361
+ onClick={() => setShowTimeline((prev) => !prev)}
362
+ className="group/timeline flex w-full items-center justify-between gap-2 py-2 rounded-md transition-colors hover:bg-muted/40 -mx-2 px-2 cursor-pointer"
363
+ >
364
+ <div className="flex items-center gap-2">
365
+ <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
366
+ {!showTimeline && attentionCount != null && attentionCount > 0 && (
367
+ <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
368
+ {attentionCount} new
369
+ </span>
370
+ )}
371
+ {!showTimeline && (lastActivityTime || (timelineEvents.length > 0 && timelineEvents[0].time)) && (
372
+ <span className="text-[11px] text-muted-foreground/60">
373
+ &middot; Last activity {lastActivityTime ?? timelineEvents[0]?.time ?? ''}
374
+ </span>
463
375
  )}
464
376
  </div>
465
-
466
- {/* Timeline body */}
467
- {showTimeline && visibleEvents.length > 0 && (
468
- <div className="mt-3">
469
- <TimelineActivity events={visibleEvents} />
470
- </div>
471
- )}
472
-
473
- {/* Footer hint */}
474
- {showTimeline && !showSystemEvents && timelineSystemEventsHiddenHint && hasSystemNoise && (
475
- <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
476
- {timelineSystemEventsHiddenHint}
477
- </p>
478
- )}
479
- {showTimeline && showSystemEvents && timelineSystemEventsVisibleHint && hasSystemNoise && (
480
- <p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
481
- {timelineSystemEventsVisibleHint.replace("{count}", String(hiddenCount))}
482
- </p>
483
- )}
484
- </div>
485
- )
486
- })()}
377
+ <div className="flex items-center gap-1.5">
378
+ <span className="text-[11px] font-medium text-muted-foreground">{timelineEvents.length} events</span>
379
+ <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
380
+ </div>
381
+ </button>
382
+ {showTimeline && (
383
+ <div className="mt-3">
384
+ <TimelineActivity events={timelineEvents} />
385
+ </div>
386
+ )}
387
+ </div>
388
+ )}
487
389
  </div>
488
390
 
489
391
  {/* Suggested Actions */}
@@ -1,137 +0,0 @@
1
- import { describe, it, expect } from "vitest"
2
- import React from "react"
3
- import { render, screen } from "@testing-library/react"
4
- import {
5
- TimelineActivity,
6
- TONE_CLASSES,
7
- type TimelineEvent,
8
- } from "../timeline-activity"
9
-
10
- // ---------------------------------------------------------------------------
11
- // Helpers
12
- // ---------------------------------------------------------------------------
13
-
14
- function minimal(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
15
- return {
16
- id: "e1",
17
- icon: React.createElement("span", { "data-testid": "icon" }, "⚡"),
18
- title: "Test event",
19
- time: "2h ago",
20
- ...overrides,
21
- }
22
- }
23
-
24
- // ---------------------------------------------------------------------------
25
- // Tests
26
- // ---------------------------------------------------------------------------
27
-
28
- describe("TimelineActivity", () => {
29
- // --- Tone rendering ---
30
-
31
- it("renders red dot classes when tone is 'red'", () => {
32
- const event = minimal({ tone: "red" })
33
- const { container } = render(<TimelineActivity events={[event]} />)
34
- const dot = container.querySelector('[data-testid="timeline-dot"]')!
35
- expect(dot).not.toBeNull()
36
- const cls = dot.className
37
- // Should contain all the red tone dot classes
38
- expect(cls).toContain("bg-red-50")
39
- expect(cls).toContain("border-red-200")
40
- // Should contain the red icon classes
41
- expect(cls).toContain("text-red-600")
42
- // Should NOT contain neutral classes
43
- expect(cls).not.toContain("border-border/60")
44
- expect(cls).not.toContain("text-muted-foreground")
45
- })
46
-
47
- it("renders neutral dot classes when tone is absent", () => {
48
- const event = minimal()
49
- const { container } = render(<TimelineActivity events={[event]} />)
50
- const dot = container.querySelector('[data-testid="timeline-dot"]')!
51
- expect(dot).not.toBeNull()
52
- const cls = dot.className
53
- expect(cls).toContain("border-border/60")
54
- expect(cls).toContain("bg-background")
55
- expect(cls).toContain("text-muted-foreground")
56
- })
57
-
58
- // --- Actor byline ---
59
-
60
- it("renders actor byline with name when actor.kind is 'user'", () => {
61
- const event = minimal({
62
- actor: { kind: "user", name: "Alice" },
63
- })
64
- render(<TimelineActivity events={[event]} />)
65
- const byline = screen.getByTestId("actor-byline")
66
- expect(byline).not.toBeNull()
67
- expect(byline.textContent).toContain("Alice")
68
- expect(byline.textContent).toContain("performed this action")
69
- })
70
-
71
- it("renders no byline when actor.kind is 'system'", () => {
72
- const event = minimal({
73
- actor: { kind: "system" },
74
- })
75
- const { container } = render(<TimelineActivity events={[event]} />)
76
- const byline = container.querySelector('[data-testid="actor-byline"]')
77
- expect(byline).toBeNull()
78
- })
79
-
80
- it("renders 'Integration' text when actor.kind is 'integration'", () => {
81
- const event = minimal({
82
- actor: { kind: "integration" },
83
- })
84
- render(<TimelineActivity events={[event]} />)
85
- const byline = screen.getByTestId("actor-byline")
86
- expect(byline).not.toBeNull()
87
- expect(byline.textContent).toContain("Integration")
88
- })
89
-
90
- it("renders custom verb for user actor", () => {
91
- const event = minimal({
92
- actor: { kind: "user", name: "Bob", verb: "approved this case" },
93
- })
94
- render(<TimelineActivity events={[event]} />)
95
- const byline = screen.getByTestId("actor-byline")
96
- expect(byline.textContent).toContain("approved this case")
97
- })
98
-
99
- it("renders no byline when actor is absent", () => {
100
- const event = minimal()
101
- const { container } = render(<TimelineActivity events={[event]} />)
102
- const byline = container.querySelector('[data-testid="actor-byline"]')
103
- expect(byline).toBeNull()
104
- })
105
-
106
- // --- Backwards compatibility ---
107
-
108
- it("renders correctly with minimal TimelineEvent (only id, icon, title, time)", () => {
109
- const event: TimelineEvent = {
110
- id: "min-1",
111
- icon: React.createElement("span", null, "📌"),
112
- title: "Minimal event",
113
- time: "5m ago",
114
- }
115
- const { container } = render(<TimelineActivity events={[event]} />)
116
- // Should render without errors
117
- expect(container.textContent).toContain("Minimal event")
118
- expect(container.textContent).toContain("5m ago")
119
- // Dot should have neutral classes
120
- const dot = container.querySelector('[data-testid="timeline-dot"]')!
121
- expect(dot.className).toContain("border-border/60")
122
- // No byline
123
- const byline = container.querySelector('[data-testid="actor-byline"]')
124
- expect(byline).toBeNull()
125
- })
126
-
127
- // --- TONE_CLASSES export ---
128
-
129
- it("exports TONE_CLASSES with all expected tones", () => {
130
- const tones = ["red", "amber", "emerald", "violet", "blue", "slate", "salesforce", "gmail"] as const
131
- for (const tone of tones) {
132
- expect(TONE_CLASSES[tone]).toBeDefined()
133
- expect(TONE_CLASSES[tone].dot).toBeTruthy()
134
- expect(TONE_CLASSES[tone].icon).toBeTruthy()
135
- }
136
- })
137
- })