@handled-ai/design-system 0.10.0 → 0.11.1

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.
@@ -18,7 +18,6 @@ import {
18
18
  ThumbsUp,
19
19
  ThumbsDown,
20
20
  Check,
21
- RefreshCw,
22
21
  ArrowLeft,
23
22
  Mail,
24
23
  Phone,
@@ -31,21 +30,8 @@ import {
31
30
  Globe,
32
31
  } from "lucide-react"
33
32
  import { Button } from "./button"
34
-
35
- // ---------------------------------------------------------------------------
36
- // Brand Icons (image-based from registry)
37
- // ---------------------------------------------------------------------------
38
-
39
- function BrandIcon({ src, alt, className }: { src: string; alt: string; className?: string }) {
40
- return (
41
- <img
42
- src={src}
43
- alt={alt}
44
- className={`${className ?? ""} object-contain`}
45
- draggable={false}
46
- />
47
- )
48
- }
33
+ import { DraftFeedbackInline } from "./draft-feedback-inline"
34
+ import { AccountContactsPopover, BrandIcon } from "./account-contacts-popover"
49
35
 
50
36
  export interface SuggestedActionsIconMap {
51
37
  gmail?: string
@@ -248,186 +234,6 @@ function AiEditPanel({ onApply }: { onApply?: (pills: string[], description: str
248
234
  )
249
235
  }
250
236
 
251
- // ---------------------------------------------------------------------------
252
- // DraftFeedbackInline
253
- // ---------------------------------------------------------------------------
254
-
255
- const positivePills = ["Tone", "Personalization", "Length", "CTA", "Other"]
256
- const negativePills = ["Too formal", "Too casual", "Too long", "Missing context", "Wrong angle", "Factual error", "Other"]
257
-
258
- function DraftFeedbackInline({
259
- onRegenerateRequest,
260
- onSubmitFeedback,
261
- onDiscardRequest,
262
- }: {
263
- onRegenerateRequest?: (pills: string[], detail: string) => void
264
- onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
265
- onDiscardRequest?: (pills: string[], detail: string) => void
266
- }) {
267
- const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(null)
268
- const [selectedPills, setSelectedPills] = React.useState<string[]>([])
269
- const [detailText, setDetailText] = React.useState("")
270
- const [noted, setNoted] = React.useState(false)
271
- const [regenerated, setRegenerated] = React.useState(false)
272
-
273
- const togglePill = React.useCallback((pill: string) => {
274
- setSelectedPills((prev) => (prev.includes(pill) ? prev.filter((p) => p !== pill) : [...prev, pill]))
275
- }, [])
276
-
277
- const handleSubmit = React.useCallback(() => {
278
- if (!thumbState) return
279
- onSubmitFeedback?.(thumbState, selectedPills, detailText)
280
- setNoted(true)
281
- setTimeout(() => {
282
- setThumbState(null)
283
- setSelectedPills([])
284
- setDetailText("")
285
- setNoted(false)
286
- }, 3000)
287
- }, [thumbState, selectedPills, detailText, onSubmitFeedback])
288
-
289
- const handleRegenerate = React.useCallback(() => {
290
- if (!thumbState) return
291
- onRegenerateRequest?.(selectedPills, detailText)
292
- setRegenerated(true)
293
- setTimeout(() => {
294
- setThumbState(null)
295
- setSelectedPills([])
296
- setDetailText("")
297
- setRegenerated(false)
298
- }, 3000)
299
- }, [thumbState, selectedPills, detailText, onRegenerateRequest])
300
-
301
- const handleDiscard = React.useCallback(() => {
302
- if (!thumbState) return
303
- onDiscardRequest?.(selectedPills, detailText)
304
- }, [thumbState, selectedPills, detailText, onDiscardRequest])
305
-
306
- if (noted) {
307
- return (
308
- <div className="flex items-center gap-1.5 py-1 animate-in fade-in slide-in-from-top-1 duration-200">
309
- <Check className="w-3.5 h-3.5 text-emerald-500" />
310
- <span className="text-xs text-muted-foreground">Feedback recorded</span>
311
- </div>
312
- )
313
- }
314
-
315
- if (regenerated) {
316
- return (
317
- <div className="py-2 animate-in fade-in slide-in-from-top-1 duration-200">
318
- <div className="flex items-center gap-2 px-3 py-2 rounded-md bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-200 dark:border-indigo-800">
319
- <RefreshCw className="w-3 h-3 text-indigo-500 animate-spin" />
320
- <span className="text-xs font-medium text-indigo-600 dark:text-indigo-400">Regenerating draft...</span>
321
- </div>
322
- </div>
323
- )
324
- }
325
-
326
- return (
327
- <div className="space-y-0">
328
- <div className="flex items-center justify-between">
329
- <span className="text-sm text-foreground font-medium">How&apos;s this draft?</span>
330
- <div className="flex gap-1">
331
- <button
332
- onClick={() => {
333
- setThumbState(thumbState === "up" ? null : "up")
334
- setSelectedPills([])
335
- setDetailText("")
336
- }}
337
- className={`p-1.5 rounded transition-colors ${
338
- thumbState === "up"
339
- ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400"
340
- : "hover:bg-muted text-muted-foreground hover:text-foreground"
341
- }`}
342
- >
343
- <ThumbsUp className="w-4 h-4" fill={thumbState === "up" ? "currentColor" : "none"} />
344
- </button>
345
- <button
346
- onClick={() => {
347
- setThumbState(thumbState === "down" ? null : "down")
348
- setSelectedPills([])
349
- setDetailText("")
350
- }}
351
- className={`p-1.5 rounded transition-colors ${
352
- thumbState === "down"
353
- ? "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400"
354
- : "hover:bg-muted text-muted-foreground hover:text-foreground"
355
- }`}
356
- >
357
- <ThumbsDown className="w-4 h-4" fill={thumbState === "down" ? "currentColor" : "none"} />
358
- </button>
359
- </div>
360
- </div>
361
-
362
- {thumbState && (
363
- <div className="pt-3 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
364
- <div>
365
- <span className="text-xs text-muted-foreground mb-2 block font-medium">
366
- {thumbState === "up" ? "What worked well?" : "What needs improvement?"}
367
- </span>
368
- <div className="flex flex-wrap gap-1.5">
369
- {(thumbState === "up" ? positivePills : negativePills).map((pill) => (
370
- <button
371
- key={pill}
372
- onClick={() => togglePill(pill)}
373
- className={`px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors ${
374
- selectedPills.includes(pill)
375
- ? thumbState === "up"
376
- ? "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800"
377
- : "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800"
378
- : "bg-background text-muted-foreground border-border hover:bg-muted/50 hover:text-foreground"
379
- }`}
380
- >
381
- {pill}
382
- </button>
383
- ))}
384
- </div>
385
- </div>
386
-
387
- <textarea
388
- value={detailText}
389
- onChange={(e) => setDetailText(e.target.value)}
390
- placeholder={thumbState === "up" ? "Add specific praise (optional)..." : "Provide specific instructions (optional)..."}
391
- className="w-full text-xs bg-background border border-border rounded-md px-2.5 py-2 text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 resize-none min-h-[60px]"
392
- />
393
-
394
- <div className="flex items-center gap-2 pt-1">
395
- {thumbState === "down" ? (
396
- <>
397
- <button
398
- onClick={handleRegenerate}
399
- disabled={selectedPills.length === 0 && detailText.length === 0}
400
- className={`flex-1 py-1.5 rounded-md text-xs font-semibold transition-colors flex items-center justify-center gap-1.5 ${
401
- selectedPills.length > 0 || detailText.length > 0
402
- ? "bg-foreground text-background hover:bg-foreground/90"
403
- : "bg-muted text-muted-foreground cursor-not-allowed"
404
- }`}
405
- >
406
- <RefreshCw className="w-3 h-3" />
407
- Regenerate draft
408
- </button>
409
- <button
410
- onClick={handleDiscard}
411
- className="flex-1 py-1.5 rounded-md text-xs font-medium transition-colors border bg-background text-foreground border-border hover:bg-muted/50 flex items-center justify-center gap-1.5"
412
- >
413
- Discard draft
414
- </button>
415
- </>
416
- ) : (
417
- <button
418
- onClick={handleSubmit}
419
- className="flex-1 py-1.5 rounded-md text-xs font-semibold transition-colors bg-foreground text-background hover:bg-foreground/90 border-transparent"
420
- >
421
- Submit feedback
422
- </button>
423
- )}
424
- </div>
425
- </div>
426
- )}
427
- </div>
428
- )
429
- }
430
-
431
237
  // ---------------------------------------------------------------------------
432
238
  // EditorToolbar
433
239
  // ---------------------------------------------------------------------------
@@ -696,173 +502,6 @@ function ContactCard({
696
502
  )
697
503
  }
698
504
 
699
- // ---------------------------------------------------------------------------
700
- // AccountContactsPopover
701
- // ---------------------------------------------------------------------------
702
-
703
- function AccountContactsPopover({
704
- contacts,
705
- onSelect,
706
- onSelectTo,
707
- onSelectCc,
708
- onSelectBcc,
709
- onViewAll,
710
- onOpenRecentActivity,
711
- trigger,
712
- iconMap,
713
- }: {
714
- contacts: SuggestedContact[]
715
- onSelect: (contact: SuggestedContact) => void
716
- onSelectTo?: (contact: SuggestedContact) => void
717
- onSelectCc?: (contact: SuggestedContact) => void
718
- onSelectBcc?: (contact: SuggestedContact) => void
719
- onViewAll?: () => void
720
- onOpenRecentActivity?: () => void
721
- trigger: React.ReactNode
722
- iconMap?: SuggestedActionsIconMap
723
- }) {
724
- const [open, setOpen] = React.useState(false)
725
- const triggerRef = React.useRef<HTMLDivElement>(null)
726
- const [popoverStyle, setPopoverStyle] = React.useState<React.CSSProperties>({})
727
-
728
- React.useEffect(() => {
729
- if (open && triggerRef.current) {
730
- const rect = triggerRef.current.getBoundingClientRect()
731
- const popoverWidth = Math.min(448, window.innerWidth - 32)
732
- let left = rect.right - popoverWidth
733
- if (left < 16) left = 16
734
- if (left + popoverWidth > window.innerWidth - 16) left = window.innerWidth - 16 - popoverWidth
735
- setPopoverStyle({ position: "fixed", top: rect.bottom + 4, left })
736
- }
737
- }, [open])
738
-
739
- return (
740
- <div>
741
- <div ref={triggerRef} onClick={() => setOpen(!open)}>{trigger}</div>
742
- {open && (
743
- <>
744
- <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
745
- <div style={popoverStyle} className="fixed bg-background border border-border rounded-lg shadow-xl z-50 w-[28rem] max-w-[calc(100vw-2rem)] py-2 animate-in fade-in slide-in-from-top-1 duration-150">
746
- <div className="px-3 py-1.5 text-[11px] font-medium text-muted-foreground/60 uppercase tracking-wide">
747
- Account Contacts
748
- </div>
749
- <div className="max-h-48 overflow-y-auto">
750
- {contacts.map((c, i) => (
751
- <div
752
- key={i}
753
- role="button"
754
- onClick={() => { (onSelectTo ?? onSelect)(c); setOpen(false) }}
755
- className="flex items-center gap-3 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors cursor-pointer"
756
- >
757
- <div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center text-[10px] font-medium text-muted-foreground shrink-0">
758
- {c.name.split(" ").map((n) => n[0]).join("")}
759
- </div>
760
- <div className="flex-1 min-w-0 overflow-hidden">
761
- <div className="truncate text-sm font-medium text-foreground">{c.name}</div>
762
- <div className="truncate text-xs text-muted-foreground leading-tight">
763
- {c.role} · {c.email ?? c.emails?.[0] ?? c.phone ?? c.phones?.[0] ?? ""}
764
- </div>
765
- {c.lastActivity && (
766
- <button
767
- type="button"
768
- onClick={(e) => {
769
- e.stopPropagation()
770
- onOpenRecentActivity?.()
771
- setOpen(false)
772
- }}
773
- className="mt-1.5 flex max-w-full items-center gap-1.5 overflow-hidden rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
774
- >
775
- <Clock className="w-3 h-3 shrink-0" />
776
- <span className="shrink-0 font-medium">Last activity</span>
777
- <span className="shrink-0 text-muted-foreground/60">·</span>
778
- <span className="shrink-0">{c.lastActivity.date}</span>
779
- <span className="shrink-0 text-muted-foreground/60">·</span>
780
- <span className="truncate capitalize">{c.lastActivity.type}</span>
781
- </button>
782
- )}
783
- </div>
784
- <div className="ml-2 flex items-center gap-1.5 shrink-0">
785
- {onSelectTo && (
786
- <button
787
- type="button"
788
- onClick={(e) => {
789
- e.stopPropagation()
790
- onSelectTo(c)
791
- setOpen(false)
792
- }}
793
- className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
794
- >
795
- To
796
- </button>
797
- )}
798
- {onSelectCc && (
799
- <button
800
- type="button"
801
- onClick={(e) => {
802
- e.stopPropagation()
803
- onSelectCc(c)
804
- setOpen(false)
805
- }}
806
- className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
807
- >
808
- Cc
809
- </button>
810
- )}
811
- {onSelectBcc && (
812
- <button
813
- type="button"
814
- onClick={(e) => {
815
- e.stopPropagation()
816
- onSelectBcc(c)
817
- setOpen(false)
818
- }}
819
- className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
820
- >
821
- Bcc
822
- </button>
823
- )}
824
- <button
825
- type="button"
826
- onClick={(e) => {
827
- e.stopPropagation()
828
- if (c.salesforceUrl) {
829
- window.open(c.salesforceUrl, "_blank", "noopener,noreferrer")
830
- } else {
831
- onViewAll?.()
832
- }
833
- }}
834
- className="h-7 w-7 inline-flex items-center justify-center rounded-md border border-border bg-background hover:bg-muted/40 transition-colors shrink-0"
835
- aria-label={`Open ${c.name} in Salesforce`}
836
- >
837
- {iconMap?.salesforce ? (
838
- <BrandIcon src={iconMap.salesforce} alt="Salesforce" className="w-3.5 h-3.5" />
839
- ) : (
840
- <ExternalLink className="w-3.5 h-3.5 text-muted-foreground" />
841
- )}
842
- </button>
843
- </div>
844
- </div>
845
- ))}
846
- </div>
847
- {onViewAll && (
848
- <>
849
- <div className="h-px bg-border mx-3 my-1" />
850
- <button
851
- onClick={() => { onViewAll(); setOpen(false) }}
852
- className="flex items-center gap-2 w-full px-3 py-2 text-left text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
853
- >
854
- <ExternalLink className="w-3 h-3" />
855
- View all contacts
856
- </button>
857
- </>
858
- )}
859
- </div>
860
- </>
861
- )}
862
- </div>
863
- )
864
- }
865
-
866
505
  // ---------------------------------------------------------------------------
867
506
  // EmailHeader (Notion Mail style)
868
507
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -77,6 +77,10 @@ export * from "./components/status-badge"
77
77
  export * from "./components/step-timeline"
78
78
  export * from "./components/sticky-action-bar"
79
79
  export * from "./components/styled-bar-list"
80
+ export { DraftFeedbackInline } from "./components/draft-feedback-inline"
81
+ export type { DraftFeedbackInlineProps } from "./components/draft-feedback-inline"
82
+ export { AccountContactsPopover, BrandIcon } from "./components/account-contacts-popover"
83
+ export type { AccountContactsPopoverProps } from "./components/account-contacts-popover"
80
84
  export * from "./components/suggested-actions"
81
85
  export * from "./components/switch"
82
86
  export * from "./components/table"
@@ -48,6 +48,12 @@ export interface SignalScoreData {
48
48
  onDismissFeedback?: (reasons: string[], detail: string, subReason?: string) => void
49
49
  initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
50
50
  initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
51
+ /** AI-generated signal brief text. When present, rendered in a dedicated section. */
52
+ signalBrief?: string
53
+ /** Compact label for time-remaining chip (e.g., "13 days left"). */
54
+ timeChipLabel?: string
55
+ /** Expanded detail for time chip popover (e.g., "Day 2 of 14 · Event detected on May 1"). */
56
+ timeChipDetail?: string
51
57
  }
52
58
 
53
59
  // ---------------------------------------------------------------------------
@@ -18,6 +18,7 @@ import {
18
18
  Columns2,
19
19
  Square,
20
20
  Tag,
21
+ Sparkles,
21
22
  } from "lucide-react"
22
23
 
23
24
  import {
@@ -238,6 +239,11 @@ export function DetailView({
238
239
  <div className="inline-flex items-center gap-1 rounded-md bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
239
240
  {item.tag1}
240
241
  </div>
242
+ {signalData.timeChipLabel && (
243
+ <Badge variant="outline" title={signalData.timeChipDetail ?? undefined}>
244
+ {signalData.timeChipLabel}
245
+ </Badge>
246
+ )}
241
247
  <button
242
248
  type="button"
243
249
  onClick={onOpenEntityPanel}
@@ -300,9 +306,16 @@ export function DetailView({
300
306
  {briefIntro}
301
307
  </p>
302
308
  ) : null}
303
- <p className="text-sm text-foreground/90 leading-relaxed mb-4">
304
- {signalData.whyNow}
305
- </p>
309
+ {signalData.signalBrief ? (
310
+ <div className="flex items-start gap-2 text-sm text-foreground/90 leading-relaxed mb-4">
311
+ <Sparkles className="h-4 w-4 mt-0.5 shrink-0 text-primary" />
312
+ <span>{signalData.signalBrief}</span>
313
+ </div>
314
+ ) : (
315
+ <p className="text-sm text-foreground/90 leading-relaxed mb-4">
316
+ {signalData.whyNow}
317
+ </p>
318
+ )}
306
319
 
307
320
  <ScoreFeedback.Root
308
321
  onSubmitFeedback={(type, pills, detail) => (signalData.onScoreFeedback ?? onScoreFeedback)?.(type, pills, detail)}