@handled-ai/design-system 0.20.16 → 0.20.17

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.
@@ -13,8 +13,16 @@ type RichTextAction =
13
13
  | "align" | "list"
14
14
  | "delete"
15
15
 
16
+ interface RichTextFontOption {
17
+ label: string
18
+ value: string
19
+ }
20
+
16
21
  interface RichTextToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
17
22
  onAction?: (action: RichTextAction) => void
23
+ fontOptions?: RichTextFontOption[]
24
+ selectedFontFamily?: string
25
+ onFontFamilyChange?: (fontFamily: string) => void
18
26
  }
19
27
 
20
28
  function ToolbarButton({
@@ -43,7 +51,32 @@ function ToolbarButton({
43
51
  )
44
52
  }
45
53
 
46
- function RichTextToolbar({ onAction, className, ...rest }: RichTextToolbarProps) {
54
+ function RichTextToolbar({
55
+ onAction,
56
+ className,
57
+ fontOptions,
58
+ selectedFontFamily,
59
+ onFontFamilyChange,
60
+ ...rest
61
+ }: RichTextToolbarProps) {
62
+ const [fontMenuOpen, setFontMenuOpen] = React.useState(false)
63
+ const fontMenuRef = React.useRef<HTMLDivElement | null>(null)
64
+ const selectedFontOption = fontOptions?.find((option) => option.value === selectedFontFamily) ?? fontOptions?.[0]
65
+ const fontLabel = selectedFontOption?.label ?? "Sans Serif"
66
+ const hasFontMenu = Boolean(fontOptions?.length && onFontFamilyChange)
67
+
68
+ React.useEffect(() => {
69
+ if (!fontMenuOpen) return
70
+
71
+ function handleDocumentMouseDown(event: MouseEvent) {
72
+ if (fontMenuRef.current?.contains(event.target as Node)) return
73
+ setFontMenuOpen(false)
74
+ }
75
+
76
+ document.addEventListener("mousedown", handleDocumentMouseDown)
77
+ return () => document.removeEventListener("mousedown", handleDocumentMouseDown)
78
+ }, [fontMenuOpen])
79
+
47
80
  return (
48
81
  <div
49
82
  data-slot="rich-text-toolbar"
@@ -58,17 +91,48 @@ function RichTextToolbar({ onAction, className, ...rest }: RichTextToolbarProps)
58
91
 
59
92
  <div className="w-px h-4 bg-border mx-1" aria-hidden="true" />
60
93
 
61
- <button
62
- type="button"
63
- data-slot="rich-text-toolbar-button"
64
- onClick={() => onAction?.("font")}
65
- aria-label="Font family"
66
- aria-haspopup="true"
67
- className="text-[11px] text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/50 cursor-pointer flex items-center gap-1"
68
- >
69
- Sans Serif
70
- <ChevronDown size={10} />
71
- </button>
94
+ <div className="relative" ref={fontMenuRef}>
95
+ <button
96
+ type="button"
97
+ data-slot="rich-text-toolbar-button"
98
+ onClick={() => {
99
+ onAction?.("font")
100
+ if (hasFontMenu) setFontMenuOpen((open) => !open)
101
+ }}
102
+ aria-label="Font family"
103
+ aria-haspopup="menu"
104
+ aria-expanded={hasFontMenu ? fontMenuOpen : undefined}
105
+ className="text-[11px] text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/50 cursor-pointer flex items-center gap-1"
106
+ >
107
+ {fontLabel}
108
+ <ChevronDown size={10} />
109
+ </button>
110
+
111
+ {hasFontMenu && fontMenuOpen ? (
112
+ <div
113
+ role="menu"
114
+ aria-label="Font family"
115
+ className="absolute left-0 bottom-full z-50 mb-1 min-w-32 overflow-hidden rounded-md border border-border bg-background py-1 shadow-md"
116
+ >
117
+ {fontOptions!.map((option) => (
118
+ <button
119
+ key={option.value}
120
+ type="button"
121
+ role="menuitemradio"
122
+ aria-checked={option.value === selectedFontFamily}
123
+ className="block w-full px-2.5 py-1.5 text-left text-xs text-foreground hover:bg-muted/60"
124
+ style={{ fontFamily: option.value }}
125
+ onClick={() => {
126
+ onFontFamilyChange?.(option.value)
127
+ setFontMenuOpen(false)
128
+ }}
129
+ >
130
+ {option.label}
131
+ </button>
132
+ ))}
133
+ </div>
134
+ ) : null}
135
+ </div>
72
136
 
73
137
  <div className="w-px h-4 bg-border mx-1" aria-hidden="true" />
74
138
 
@@ -87,4 +151,4 @@ function RichTextToolbar({ onAction, className, ...rest }: RichTextToolbarProps)
87
151
  )
88
152
  }
89
153
 
90
- export { RichTextToolbar, type RichTextToolbarProps, type RichTextAction }
154
+ export { RichTextToolbar, type RichTextToolbarProps, type RichTextAction, type RichTextFontOption }
@@ -152,24 +152,6 @@ describe("DetailView case-panel-v2 section layout", () => {
152
152
  )
153
153
  })
154
154
 
155
- it("adds a consistent stack around the case-panel workflow sections", () => {
156
- const { container } = renderDetailView({
157
- sectionLayout: "case-panel-v2",
158
- renderPrimaryAction: () => <section>Primary action marker</section>,
159
- renderCommentArea: () => <section>Comment area marker</section>,
160
- })
161
-
162
- const stack = container.querySelector('[data-slot="case-panel-workflow-stack"]')
163
- const commentArea = container.querySelector('[data-slot="case-panel-comment-area"]')
164
- expect(stack?.className).toContain("space-y-10")
165
- expect(commentArea).not.toBeNull()
166
- expectInDocumentOrder(
167
- screen.getByText("Primary action marker"),
168
- screen.getByText("Comment area marker"),
169
- screen.getByText(/activity timeline/i),
170
- )
171
- })
172
-
173
155
  it("renders signal brief and chip-backed Why section without a separate The why block", () => {
174
156
  renderDetailView({ sectionLayout: "case-panel-v2" })
175
157
 
@@ -142,58 +142,6 @@ describe("DetailView timeline system-events toggle", () => {
142
142
  expect(container.textContent).not.toContain("Score updated -1")
143
143
  })
144
144
 
145
- it("keeps case-panel system events off by default even when localStorage has a stale visible value", async () => {
146
- store["test-show-score-changes"] = "true"
147
- const { container } = render(<DetailView {...baseProps({ sectionLayout: "case-panel-v2" })} />)
148
- expandTimeline(container)
149
-
150
- const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
151
- const eventCount = container.querySelector('[data-testid="event-count"]')
152
- expect(toggle).toHaveAttribute("aria-pressed", "false")
153
- expect(eventCount?.textContent).toBe("2 events")
154
- expect(container.textContent).not.toContain("Score updated +3")
155
- expect(localStorageMock.setItem).not.toHaveBeenCalledWith("test-show-score-changes", "false")
156
- })
157
-
158
- it("resets case-panel system events to the default when the selected item changes", () => {
159
- const { container, rerender } = render(
160
- <DetailView {...baseProps({ sectionLayout: "case-panel-v2" })} />,
161
- )
162
- expandTimeline(container)
163
- let toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
164
-
165
- fireEvent.click(toggle)
166
- expect(toggle).toHaveAttribute("aria-pressed", "true")
167
- expect(container.textContent).toContain("Score updated +3")
168
-
169
- rerender(
170
- <DetailView
171
- {...baseProps({
172
- sectionLayout: "case-panel-v2",
173
- item: { ...baseItem, id: "2", title: "Second Signal" },
174
- })}
175
- />,
176
- )
177
- expandTimeline(container)
178
- toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
179
-
180
- expect(toggle).toHaveAttribute("aria-pressed", "false")
181
- expect(container.textContent).not.toContain("Score updated +3")
182
- expect(localStorageMock.setItem).not.toHaveBeenCalledWith("test-show-score-changes", "true")
183
- })
184
-
185
- it("uses a shorter, quieter system-events control in the case-panel variant", () => {
186
- const { container } = render(<DetailView {...baseProps({ sectionLayout: "case-panel-v2" })} />)
187
- const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
188
- const indicator = container.querySelector('[data-testid="system-events-indicator"]') as HTMLElement
189
- const count = container.querySelector('[data-testid="hidden-count-badge"]') as HTMLElement
190
-
191
- expect(toggle.className).toContain("py-1")
192
- expect(toggle.className).toContain("text-xs")
193
- expect(indicator.className).toContain("h-3.5")
194
- expect(count.className).toContain("text-[11px]")
195
- })
196
-
197
145
  it("reveals system-noise events when toggle is clicked", () => {
198
146
  const { container } = render(<DetailView {...baseProps()} />)
199
147
  expandTimeline(container)
@@ -309,12 +309,9 @@ function TimelineSection({
309
309
  type="button"
310
310
  onClick={() => setShowSystemEvents((prev) => !prev)}
311
311
  className={cn(
312
- "inline-flex shrink-0 cursor-pointer items-center rounded-full border transition-colors",
313
- isCasePanel ? "gap-2 px-2.5 py-1 text-xs font-medium" : "gap-3 px-3.5 py-2 text-sm font-semibold",
312
+ "inline-flex shrink-0 cursor-pointer items-center gap-3 rounded-full border px-3.5 py-2 text-sm font-semibold transition-colors",
314
313
  showSystemEvents
315
- ? isCasePanel
316
- ? "border-border bg-muted text-foreground shadow-sm hover:bg-muted/80"
317
- : "border-foreground bg-foreground text-background shadow-sm hover:bg-foreground/90"
314
+ ? "border-foreground bg-foreground text-background shadow-sm hover:bg-foreground/90"
318
315
  : "border-border bg-background text-muted-foreground shadow-sm hover:bg-muted/40 hover:text-foreground"
319
316
  )}
320
317
  aria-pressed={showSystemEvents}
@@ -324,25 +321,23 @@ function TimelineSection({
324
321
  >
325
322
  <span
326
323
  className={cn(
327
- "relative inline-flex shrink-0 items-center rounded-full p-0.5 transition-colors",
328
- isCasePanel ? "h-3.5 w-7" : "h-4 w-8",
329
- showSystemEvents ? "bg-teal-600" : "bg-muted-foreground/25"
324
+ "relative inline-flex h-4 w-8 shrink-0 items-center rounded-full p-0.5 transition-colors",
325
+ showSystemEvents ? "bg-teal-600" : "bg-muted-foreground/30"
330
326
  )}
331
327
  aria-hidden="true"
332
328
  data-testid="system-events-indicator"
333
329
  >
334
330
  <span
335
331
  className={cn(
336
- "block rounded-full bg-white shadow-sm transition-transform",
337
- isCasePanel ? "h-2.5 w-2.5" : "h-3 w-3",
338
- showSystemEvents ? (isCasePanel ? "translate-x-3.5" : "translate-x-4") : "translate-x-0"
332
+ "block h-3 w-3 rounded-full bg-white shadow-sm transition-transform",
333
+ showSystemEvents ? "translate-x-4" : "translate-x-0"
339
334
  )}
340
335
  />
341
336
  </span>
342
337
  <span>{toggleLabel}</span>
343
338
  {!showSystemEvents ? (
344
339
  <span
345
- className={cn("inline-flex items-center justify-center rounded-full bg-muted px-1.5 font-bold tabular-nums text-muted-foreground", isCasePanel ? "min-w-5 text-[11px]" : "min-w-[22px] text-xs")}
340
+ className="inline-flex min-w-[22px] items-center justify-center rounded-full bg-muted px-1.5 text-xs font-bold tabular-nums text-muted-foreground"
346
341
  data-testid="hidden-count-badge"
347
342
  >
348
343
  {hiddenCount}
@@ -455,8 +450,6 @@ export function DetailView({
455
450
  timelineSystemEventsVisibleHint,
456
451
  ])
457
452
 
458
- const isCasePanelV2 = sectionLayout === "case-panel-v2"
459
-
460
453
  const [showTimeline, setShowTimeline] = React.useState(false)
461
454
  const [extraActions, setExtraActions] = React.useState<SuggestedAction[]>([])
462
455
 
@@ -466,17 +459,9 @@ export function DetailView({
466
459
  const [showSystemEvents, setShowSystemEvents] = React.useState(sysEvtDefaultVisible)
467
460
  const initialReadDoneRef = React.useRef(false)
468
461
 
469
- // Read persisted value from localStorage on mount. Case-panel timelines always
470
- // start from the configured default so stale local state cannot make system
471
- // events prominent by default in the inbox detail panel.
462
+ // Read persisted value from localStorage on mount
472
463
  React.useEffect(() => {
473
- if (isCasePanelV2) {
474
- setShowSystemEvents(sysEvtDefaultVisible)
475
- initialReadDoneRef.current = true
476
- return
477
- }
478
464
  if (!sysEvtStorageKey) {
479
- setShowSystemEvents(sysEvtDefaultVisible)
480
465
  initialReadDoneRef.current = true
481
466
  return
482
467
  }
@@ -484,27 +469,23 @@ export function DetailView({
484
469
  const stored = localStorage.getItem(sysEvtStorageKey)
485
470
  if (stored !== null) {
486
471
  setShowSystemEvents(stored === "true")
487
- } else {
488
- setShowSystemEvents(sysEvtDefaultVisible)
489
472
  }
490
473
  } catch {
491
474
  // localStorage unavailable — ignore
492
- setShowSystemEvents(sysEvtDefaultVisible)
493
475
  }
494
476
  initialReadDoneRef.current = true
495
- }, [isCasePanelV2, item.id, sysEvtDefaultVisible, sysEvtStorageKey])
477
+ }, [sysEvtStorageKey])
496
478
 
497
- // Write to localStorage when the toggle changes (skip case-panel timelines so
498
- // they stay off by default on the next case/session).
479
+ // Write to localStorage when the toggle changes (skip initial if matching default)
499
480
  React.useEffect(() => {
500
- if (!sysEvtStorageKey || isCasePanelV2) return
481
+ if (!sysEvtStorageKey) return
501
482
  if (!initialReadDoneRef.current) return
502
483
  try {
503
484
  localStorage.setItem(sysEvtStorageKey, String(showSystemEvents))
504
485
  } catch {
505
486
  // localStorage unavailable — ignore
506
487
  }
507
- }, [isCasePanelV2, showSystemEvents, sysEvtStorageKey])
488
+ }, [showSystemEvents, sysEvtStorageKey])
508
489
 
509
490
  React.useEffect(() => {
510
491
  setShowTimeline(false)
@@ -547,6 +528,8 @@ export function DetailView({
547
528
  ? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-50"
548
529
  : "hover:bg-muted/50"
549
530
 
531
+ const isCasePanelV2 = sectionLayout === "case-panel-v2"
532
+
550
533
  // The metadata chips row (priority · deadline · account · renderMetadataExtra). Rendered above
551
534
  // the brief by default, or beneath it when `metadataLayout === "below-brief"` (case-panel redesign).
552
535
  const metadataChips = (
@@ -779,16 +762,10 @@ export function DetailView({
779
762
  </>
780
763
  ) : null}
781
764
 
782
- <div data-slot="case-panel-workflow-stack" className="space-y-10">
783
- {/* After-score content slot (e.g. OpportunityPanel) */}
784
- {renderAfterScore?.(item)}
785
- {renderPrimaryAction?.(item)}
786
- {renderCommentArea ? (
787
- <div data-slot="case-panel-comment-area">
788
- {renderCommentArea(item)}
789
- </div>
790
- ) : null}
791
- </div>
765
+ {/* After-score content slot (e.g. OpportunityPanel) */}
766
+ {renderAfterScore?.(item)}
767
+ {renderPrimaryAction?.(item)}
768
+ {renderCommentArea?.(item)}
792
769
  {timelineSection}
793
770
  </>
794
771
  ) : (