@handled-ai/design-system 0.19.1 → 0.20.0

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.
@@ -28,6 +28,7 @@ import {
28
28
  CornerUpLeft,
29
29
  CheckCheck,
30
30
  MailOpen,
31
+ FilePenLine,
31
32
  Reply,
32
33
  ReplyAll,
33
34
  Eye,
@@ -75,7 +76,7 @@ export interface ConvMessage {
75
76
  date: string
76
77
  /** Relative label, e.g. "2 days ago". */
77
78
  ago?: string
78
- receipt?: { kind: "new" | "read" | "opened" | "sent"; label: string }
79
+ receipt?: { kind: "new" | "read" | "opened" | "sent" | "draft"; label: string }
79
80
  /** HTML body (preferred). Sanitized by the component before rendering. */
80
81
  bodyHtml?: string
81
82
  /** Plain-text fallback when `bodyHtml` is absent. */
@@ -84,7 +85,7 @@ export interface ConvMessage {
84
85
  quoted?: { attr: string; html: string }
85
86
  }
86
87
 
87
- export type ConvStatus = "responded" | "awaiting" | "viewing"
88
+ export type ConvStatus = "responded" | "awaiting" | "viewing" | "draft"
88
89
 
89
90
  export interface ConversationThread {
90
91
  threadId: string
@@ -181,12 +182,14 @@ function firstName(name: string): string {
181
182
 
182
183
  const STATUS_PILL: Record<ConvStatus, { label: string; cls: string }> = {
183
184
  responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
185
+ draft: { label: "Draft", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
184
186
  awaiting: { label: "Awaiting", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
185
187
  viewing: { label: "Viewing", cls: "bg-muted text-muted-foreground border-border" },
186
188
  }
187
189
 
188
190
  const STATUS_DOT: Record<ConvStatus, string> = {
189
191
  responded: "bg-status-active-fg",
192
+ draft: "bg-status-pending-fg",
190
193
  awaiting: "bg-status-pending-fg",
191
194
  viewing: "bg-muted-foreground/50",
192
195
  }
@@ -257,6 +260,8 @@ function MessageView({
257
260
  <CornerUpLeft size={11} />
258
261
  ) : message.receipt.kind === "read" ? (
259
262
  <CheckCheck size={11} />
263
+ ) : message.receipt.kind === "draft" ? (
264
+ <FilePenLine size={11} />
260
265
  ) : (
261
266
  <MailOpen size={11} />
262
267
  )}
@@ -696,32 +701,37 @@ function ConversationPanel({
696
701
  className,
697
702
  }: ConversationPanelProps) {
698
703
  const responded = threads.filter((t) => t.status === "responded" && t.canReply !== false).length
704
+ const draft = threads.filter((t) => effectiveStatus(t) === "draft").length
699
705
  const awaiting = threads.filter((t) => effectiveStatus(t) === "awaiting").length
700
706
  const anyPaused = threads.some((t) => t.paused)
701
707
 
702
708
  const [hubOpen, setHubOpen] = React.useState(true)
703
709
  const [openId, setOpenId] = React.useState<string | null>(() => {
704
710
  if (defaultOpenThreadId) return defaultOpenThreadId
705
- const firstResponded = threads.find((t) => t.status === "responded" && t.canReply !== false)
706
- return firstResponded ? firstResponded.threadId : null
711
+ const firstActionable = threads.find((t) => ["responded", "draft"].includes(t.status) && t.canReply !== false)
712
+ return firstActionable ? firstActionable.threadId : null
707
713
  })
708
714
 
709
715
  if (!threads.length) return null
710
716
 
711
- // Header badge state: a responded reply leads; else an awaiting state; else neutral.
717
+ // Header badge state: a responded reply leads, then drafts to finish, then sent mail awaiting a reply.
712
718
  const badge =
713
719
  responded > 0
714
720
  ? { label: "Email response detected", dot: "bg-status-active-fg", ring: "bg-status-active-fg/30" }
715
- : awaiting > 0
716
- ? { label: "Awaiting response", dot: "bg-status-pending-fg", ring: "bg-status-pending-fg/30" }
717
- : { label: "Conversations", dot: "bg-muted-foreground/50", ring: "bg-muted-foreground/20" }
721
+ : draft > 0
722
+ ? { label: "Draft ready", dot: "bg-status-pending-fg", ring: "bg-status-pending-fg/30" }
723
+ : awaiting > 0
724
+ ? { label: "Awaiting response", dot: "bg-status-pending-fg", ring: "bg-status-pending-fg/30" }
725
+ : { label: "Conversations", dot: "bg-muted-foreground/50", ring: "bg-muted-foreground/20" }
718
726
 
719
727
  const headTitle =
720
728
  responded > 0
721
729
  ? `${responded} ${responded === 1 ? "reply needs" : "replies need"} your response`
722
- : awaiting > 0
723
- ? `Awaiting response on ${awaiting} ${awaiting === 1 ? "thread" : "threads"}`
724
- : `${threads.length} email ${threads.length === 1 ? "thread" : "threads"}`
730
+ : draft > 0
731
+ ? `Draft ready on ${draft} ${draft === 1 ? "thread" : "threads"}`
732
+ : awaiting > 0
733
+ ? `Awaiting response on ${awaiting} ${awaiting === 1 ? "thread" : "threads"}`
734
+ : `${threads.length} email ${threads.length === 1 ? "thread" : "threads"}`
725
735
 
726
736
  return (
727
737
  <section
@@ -741,7 +751,7 @@ function ConversationPanel({
741
751
  "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold",
742
752
  responded > 0
743
753
  ? "bg-status-active-bg text-status-active-fg"
744
- : awaiting > 0
754
+ : draft > 0 || awaiting > 0
745
755
  ? "bg-status-pending-bg text-status-pending-fg"
746
756
  : "bg-muted text-muted-foreground"
747
757
  )}
@@ -0,0 +1,97 @@
1
+ import { cleanup, render, screen } from "@testing-library/react"
2
+ import { afterEach, describe, expect, it } from "vitest"
3
+
4
+ import { DetailView } from "../prototype-inbox-view"
5
+ import type { DetailViewProps } from "../prototype-inbox-view"
6
+ import type { SignalScoreData } from "../prototype-config"
7
+
8
+ const baseItem = {
9
+ id: "case-1",
10
+ title: "Churn risk case title",
11
+ details: "Case details",
12
+ statusColor: "red",
13
+ time: "1h ago",
14
+ company: "Acme Corp",
15
+ tag1: "Signal",
16
+ }
17
+
18
+ afterEach(() => {
19
+ cleanup()
20
+ })
21
+
22
+ function renderDetailView(
23
+ overrides: Partial<DetailViewProps> = {},
24
+ scoreOverrides: Partial<SignalScoreData> = {},
25
+ ) {
26
+ const props: DetailViewProps = {
27
+ item: baseItem,
28
+ sections: { signalBrief: true, suggestedActions: false, timeline: false },
29
+ getSignalScore: () => ({
30
+ score: 72,
31
+ factors: [],
32
+ whyNow: "Why now",
33
+ evidence: [],
34
+ confidence: 80,
35
+ urgencyLabel: "High",
36
+ urgencyExplanation: "High priority",
37
+ signalBrief: "BRIEF BODY TEXT",
38
+ timeChipLabel: "3 days left",
39
+ ...scoreOverrides,
40
+ }),
41
+ buildSuggestedActions: () => [],
42
+ buildSourceItems: () => [],
43
+ accountContacts: [],
44
+ emailSignature: "",
45
+ iconMap: {},
46
+ renderMetadataExtra: () => <span data-testid="metadata-extra">Owner: Lee</span>,
47
+ ...overrides,
48
+ }
49
+
50
+ return render(<DetailView {...props} />)
51
+ }
52
+
53
+ const PRECEDES = Node.DOCUMENT_POSITION_PRECEDING
54
+ const FOLLOWS = Node.DOCUMENT_POSITION_FOLLOWING
55
+
56
+ describe("DetailView metadataLayout", () => {
57
+ it("renders the metadata chips above the brief by default", () => {
58
+ renderDetailView()
59
+
60
+ const brief = screen.getByText("BRIEF BODY TEXT")
61
+ const meta = screen.getByTestId("metadata-extra")
62
+ // metadata-extra comes before the brief body in document order
63
+ expect(brief.compareDocumentPosition(meta) & PRECEDES).toBeTruthy()
64
+ expect(screen.getByTestId("priority-popover-trigger")).toBeTruthy()
65
+ })
66
+
67
+ it("relocates the metadata chips below the brief when metadataLayout='below-brief'", () => {
68
+ renderDetailView({ metadataLayout: "below-brief" })
69
+
70
+ const brief = screen.getByText("BRIEF BODY TEXT")
71
+ const meta = screen.getByTestId("metadata-extra")
72
+ // metadata-extra now follows the brief body
73
+ expect(brief.compareDocumentPosition(meta) & FOLLOWS).toBeTruthy()
74
+ // chips themselves (priority + account + deadline) render exactly once
75
+ expect(screen.getAllByTestId("priority-popover-trigger")).toHaveLength(1)
76
+ expect(screen.getByText("3 days left")).toBeTruthy()
77
+ })
78
+ })
79
+
80
+ describe("DetailView deadline chip tone", () => {
81
+ it("applies the red tone when timeChipTone='red'", () => {
82
+ renderDetailView({}, { timeChipTone: "red" })
83
+ expect(screen.getByText("3 days left").className).toContain("text-red-700")
84
+ })
85
+
86
+ it("applies the amber tone when timeChipTone='amber'", () => {
87
+ renderDetailView({}, { timeChipTone: "amber" })
88
+ expect(screen.getByText("3 days left").className).toContain("text-amber-700")
89
+ })
90
+
91
+ it("uses a neutral tone when timeChipTone is omitted", () => {
92
+ renderDetailView()
93
+ const chip = screen.getByText("3 days left")
94
+ expect(chip.className).not.toContain("text-red-700")
95
+ expect(chip.className).not.toContain("text-amber-700")
96
+ })
97
+ })
@@ -142,6 +142,11 @@ export interface SignalScoreData {
142
142
  timeChipLabel?: string
143
143
  /** Expanded detail for time chip popover (e.g., "Day 2 of 14 · Event detected on May 1"). */
144
144
  timeChipDetail?: string
145
+ /**
146
+ * Visual urgency tone for the deadline chip. The consumer computes this from the live deadline
147
+ * (e.g. amber when a few days remain, red when due/overdue). Defaults to neutral when omitted.
148
+ */
149
+ timeChipTone?: "neutral" | "amber" | "red"
145
150
  }
146
151
 
147
152
  // ---------------------------------------------------------------------------
@@ -169,6 +169,13 @@ export interface DetailViewProps {
169
169
  renderTitleSubtext?: (item: QueueItem) => React.ReactNode
170
170
  /** Render extra metadata chips (e.g. assignee) inside the chips row below the title. */
171
171
  renderMetadataExtra?: (item: QueueItem) => React.ReactNode
172
+ /**
173
+ * Where the metadata chips row (priority · deadline · account · renderMetadataExtra) sits.
174
+ * - "default": above the signal brief (legacy layout, unchanged for existing consumers).
175
+ * - "below-brief": moved beneath the brief text, just before `renderBeforeScore`
176
+ * (case-panel redesign — chips + owners read as one block under the brief).
177
+ */
178
+ metadataLayout?: "default" | "below-brief"
172
179
  /** Override the built-in account details metadata button label. */
173
180
  accountDetailsButtonLabel?: (item: QueueItem) => React.ReactNode
174
181
  /** Accessible label for the built-in account details metadata button. */
@@ -352,6 +359,7 @@ export function DetailView({
352
359
  renderTitleActionRow,
353
360
  renderTitleSubtext,
354
361
  renderMetadataExtra,
362
+ metadataLayout = "default",
355
363
  accountDetailsButtonLabel,
356
364
  getAccountDetailsButtonAriaLabel,
357
365
  onOpenSignalBucket,
@@ -465,6 +473,84 @@ export function DetailView({
465
473
  [suggestedActions],
466
474
  )
467
475
 
476
+ // Deadline chip tone (case-panel redesign): amber as the deadline nears, red when due/overdue.
477
+ const timeChipToneClass =
478
+ signalData.timeChipTone === "red"
479
+ ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-50"
480
+ : signalData.timeChipTone === "amber"
481
+ ? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-50"
482
+ : "hover:bg-muted/50"
483
+
484
+ // The metadata chips row (priority · deadline · account · renderMetadataExtra). Rendered above
485
+ // the brief by default, or beneath it when `metadataLayout === "below-brief"` (case-panel redesign).
486
+ const metadataChips = (
487
+ <>
488
+ <SignalPriorityPopover
489
+ score={signalData.score}
490
+ urgencyLabel={signalData.urgencyLabel}
491
+ urgencyExplanation={signalData.urgencyExplanation ?? signalData.signalBrief}
492
+ scoreDisplay={signalData.priorityScoreDisplay}
493
+ formulaLabel={signalData.priorityFormulaLabel}
494
+ factors={signalData.priorityFactors ?? []}
495
+ metaText={undefined}
496
+ feedbackChips={signalData.priorityFeedbackChips}
497
+ onFeedbackSubmit={signalData.onPriorityFeedback}
498
+ initialFactorFeedback={signalData.initialFactorPopoverFeedback}
499
+ onFactorFeedback={signalData.onFactorFeedback}
500
+ initialPriorityFeedback={signalData.initialPriorityFeedback}
501
+ />
502
+ {signalData.timeChipLabel && (
503
+ <Badge
504
+ variant="outline"
505
+ title={signalData.timeChipDetail ?? undefined}
506
+ className={`transition-colors ${timeChipToneClass}`}
507
+ >
508
+ {signalData.timeChipLabel}
509
+ </Badge>
510
+ )}
511
+ <TooltipProvider delayDuration={300}>
512
+ <Tooltip>
513
+ <TooltipTrigger asChild>
514
+ <button
515
+ type="button"
516
+ onClick={onOpenEntityPanel}
517
+ className="group/account ml-1 inline-flex max-w-full items-center gap-2 rounded-md border border-border/60 bg-background px-2 py-1 transition-colors hover:border-border hover:bg-muted/50"
518
+ aria-label={getAccountDetailsButtonAriaLabel?.(item) ?? `View account details for ${item.company}`}
519
+ >
520
+ <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-foreground text-[9px] font-semibold uppercase leading-none text-background">
521
+ {getCompanyInitials(item.company)}
522
+ </span>
523
+ {accountDetailsButtonLabel ? (
524
+ <span className="inline-flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
525
+ {accountDetailsButtonLabel(item)}
526
+ </span>
527
+ ) : (
528
+ <span className="truncate text-xs font-medium text-foreground">{item.company}</span>
529
+ )}
530
+ <span className="flex shrink-0 items-center gap-1.5">
531
+ <span
532
+ aria-hidden
533
+ className={`h-1.5 w-1.5 shrink-0 rounded-full ${STATUS_DOT_CLASS[item.statusColor] ?? "bg-muted-foreground/40"}`}
534
+ />
535
+ {/* "View account" expands in on hover via an animated grid column */}
536
+ <span className="-ml-1.5 grid grid-cols-[0fr] transition-all duration-200 ease-out group-hover/account:ml-0 group-hover/account:grid-cols-[1fr]">
537
+ <span className="overflow-hidden">
538
+ <span className="block whitespace-nowrap text-xs font-medium text-muted-foreground/80">
539
+ View account
540
+ </span>
541
+ </span>
542
+ </span>
543
+ <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/50 transition-transform duration-150 group-hover/account:translate-x-0.5 group-hover/account:text-muted-foreground" />
544
+ </span>
545
+ </button>
546
+ </TooltipTrigger>
547
+ <TooltipContent>Open account panel</TooltipContent>
548
+ </Tooltip>
549
+ </TooltipProvider>
550
+ {renderMetadataExtra?.(item)}
551
+ </>
552
+ )
553
+
468
554
  return (
469
555
  <SignalApproval.Root
470
556
  key={item.id}
@@ -506,71 +592,9 @@ export function DetailView({
506
592
  ) : null}
507
593
  </div>
508
594
 
509
- <div className="mb-6 flex flex-wrap items-center gap-2">
510
- <SignalPriorityPopover
511
- score={signalData.score}
512
- urgencyLabel={signalData.urgencyLabel}
513
- urgencyExplanation={signalData.urgencyExplanation ?? signalData.signalBrief}
514
- scoreDisplay={signalData.priorityScoreDisplay}
515
- formulaLabel={signalData.priorityFormulaLabel}
516
- factors={signalData.priorityFactors ?? []}
517
- metaText={undefined}
518
- feedbackChips={signalData.priorityFeedbackChips}
519
- onFeedbackSubmit={signalData.onPriorityFeedback}
520
- initialFactorFeedback={signalData.initialFactorPopoverFeedback}
521
- onFactorFeedback={signalData.onFactorFeedback}
522
- initialPriorityFeedback={signalData.initialPriorityFeedback}
523
- />
524
- {signalData.timeChipLabel && (
525
- <Badge
526
- variant="outline"
527
- title={signalData.timeChipDetail ?? undefined}
528
- className="transition-colors hover:bg-muted/50"
529
- >
530
- {signalData.timeChipLabel}
531
- </Badge>
532
- )}
533
- <TooltipProvider delayDuration={300}>
534
- <Tooltip>
535
- <TooltipTrigger asChild>
536
- <button
537
- type="button"
538
- onClick={onOpenEntityPanel}
539
- className="group/account ml-1 inline-flex max-w-full items-center gap-2 rounded-md border border-border/60 bg-background px-2 py-1 transition-colors hover:border-border hover:bg-muted/50"
540
- aria-label={getAccountDetailsButtonAriaLabel?.(item) ?? `View account details for ${item.company}`}
541
- >
542
- <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-foreground text-[9px] font-semibold uppercase leading-none text-background">
543
- {getCompanyInitials(item.company)}
544
- </span>
545
- {accountDetailsButtonLabel ? (
546
- <span className="inline-flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
547
- {accountDetailsButtonLabel(item)}
548
- </span>
549
- ) : (
550
- <span className="truncate text-xs font-medium text-foreground">{item.company}</span>
551
- )}
552
- <span className="flex shrink-0 items-center gap-1.5">
553
- <span
554
- aria-hidden
555
- className={`h-1.5 w-1.5 shrink-0 rounded-full ${STATUS_DOT_CLASS[item.statusColor] ?? "bg-muted-foreground/40"}`}
556
- />
557
- {/* "View account" expands in on hover via an animated grid column */}
558
- <span className="-ml-1.5 grid grid-cols-[0fr] transition-all duration-200 ease-out group-hover/account:ml-0 group-hover/account:grid-cols-[1fr]">
559
- <span className="overflow-hidden">
560
- <span className="block whitespace-nowrap text-xs font-medium text-muted-foreground/80">
561
- View account
562
- </span>
563
- </span>
564
- </span>
565
- <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/50 transition-transform duration-150 group-hover/account:translate-x-0.5 group-hover/account:text-muted-foreground" />
566
- </span>
567
- </button>
568
- </TooltipTrigger>
569
- <TooltipContent>Open account panel</TooltipContent>
570
- </Tooltip>
571
- </TooltipProvider>
572
- {renderMetadataExtra?.(item)}
573
- </div>
595
+ {metadataLayout === "default" ? (
596
+ <div className="mb-6 flex flex-wrap items-center gap-2">{metadataChips}</div>
597
+ ) : null}
574
598
 
575
599
  {/* Signal Brief */}
576
600
  {sections.signalBrief && (() => {
@@ -612,6 +636,11 @@ export function DetailView({
612
636
  </p>
613
637
  )}
614
638
 
639
+ {/* Metadata chips relocated beneath the brief (case-panel redesign). */}
640
+ {metadataLayout === "below-brief" ? (
641
+ <div className="mb-4 flex flex-wrap items-center gap-2">{metadataChips}</div>
642
+ ) : null}
643
+
615
644
  {/* Before-score content slot (e.g. "Signals on Case" chips) */}
616
645
  {renderBeforeScore?.(item)}
617
646