@handled-ai/design-system 0.18.23 → 0.18.25

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 (55) hide show
  1. package/dist/components/case-panel-detail.d.ts +3 -1
  2. package/dist/components/case-panel-detail.js +29 -15
  3. package/dist/components/case-panel-detail.js.map +1 -1
  4. package/dist/components/case-panel-email-composer.d.ts +2 -1
  5. package/dist/components/case-panel-email-composer.js +4 -2
  6. package/dist/components/case-panel-email-composer.js.map +1 -1
  7. package/dist/components/data-table.js +1 -0
  8. package/dist/components/data-table.js.map +1 -1
  9. package/dist/components/score-analysis-modal.d.ts +8 -2
  10. package/dist/components/score-analysis-modal.js +19 -6
  11. package/dist/components/score-analysis-modal.js.map +1 -1
  12. package/dist/components/score-breakdown.d.ts +3 -1
  13. package/dist/components/score-breakdown.js +5 -6
  14. package/dist/components/score-breakdown.js.map +1 -1
  15. package/dist/components/score-ring.d.ts +6 -3
  16. package/dist/components/score-ring.js +11 -14
  17. package/dist/components/score-ring.js.map +1 -1
  18. package/dist/components/score-semantics.d.ts +27 -0
  19. package/dist/components/score-semantics.js +173 -0
  20. package/dist/components/score-semantics.js.map +1 -0
  21. package/dist/components/score-why-chips.d.ts +3 -2
  22. package/dist/components/score-why-chips.js +10 -21
  23. package/dist/components/score-why-chips.js.map +1 -1
  24. package/dist/components/signal-priority-popover.d.ts +1 -0
  25. package/dist/components/signal-priority-popover.js +20 -20
  26. package/dist/components/signal-priority-popover.js.map +1 -1
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/prototype/index.d.ts +1 -0
  31. package/dist/prototype/prototype-accounts-view.d.ts +1 -0
  32. package/dist/prototype/prototype-admin-view.d.ts +1 -0
  33. package/dist/prototype/prototype-config.d.ts +1 -0
  34. package/dist/prototype/prototype-inbox-view.d.ts +1 -0
  35. package/dist/prototype/prototype-insights-view.d.ts +1 -0
  36. package/dist/prototype/prototype-shell.d.ts +1 -0
  37. package/package.json +1 -1
  38. package/src/components/__tests__/case-panel-detail.test.tsx +17 -1
  39. package/src/components/__tests__/case-panel-email-composer.test.tsx +7 -0
  40. package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +4 -1
  41. package/src/components/__tests__/score-analysis-modal.test.tsx +55 -0
  42. package/src/components/__tests__/score-breakdown-intent.test.tsx +47 -0
  43. package/src/components/__tests__/score-ring.test.tsx +43 -0
  44. package/src/components/__tests__/score-semantics.test.ts +107 -0
  45. package/src/components/__tests__/signal-priority-popover.test.tsx +7 -5
  46. package/src/components/case-panel-detail.tsx +31 -13
  47. package/src/components/case-panel-email-composer.tsx +25 -21
  48. package/src/components/data-table.tsx +1 -0
  49. package/src/components/score-analysis-modal.tsx +22 -5
  50. package/src/components/score-breakdown.tsx +7 -6
  51. package/src/components/score-ring.tsx +11 -13
  52. package/src/components/score-semantics.ts +187 -0
  53. package/src/components/score-why-chips.tsx +12 -23
  54. package/src/components/signal-priority-popover.tsx +21 -21
  55. package/src/index.ts +1 -0
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import React from "react"
3
+ import { render, screen } from "@testing-library/react"
4
+ import { ScoreBreakdown, type ScoreFactor } from "../score-breakdown"
5
+
6
+ const factors: ScoreFactor[] = [
7
+ { key: "high", label: "High Factor", score: 90, why: "High score" },
8
+ { key: "low", label: "Low Factor", score: 20, why: "Low score" },
9
+ ]
10
+
11
+ function barClasses(scoreIntent: React.ComponentProps<typeof ScoreBreakdown>["scoreIntent"]) {
12
+ const { container } = render(<ScoreBreakdown factors={factors} scoreIntent={scoreIntent} />)
13
+ return Array.from(container.querySelectorAll(".h-full.rounded-full")).map((bar) => bar.getAttribute("class") ?? "")
14
+ }
15
+
16
+ describe("ScoreBreakdown intent-aware factor bars", () => {
17
+ it("preserves positive high green and low red factor bars", () => {
18
+ const [highClass, lowClass] = barClasses("positive")
19
+
20
+ expect(highClass).toContain("bg-emerald-500")
21
+ expect(lowClass).toContain("bg-red-500")
22
+ })
23
+
24
+ it("uses red for high risk factor bars", () => {
25
+ const [highClass] = barClasses("risk")
26
+
27
+ expect(highClass).toContain("bg-red-600")
28
+ })
29
+
30
+ it("uses neutral, not red, for low urgency factor bars", () => {
31
+ const [, lowClass] = barClasses("urgency")
32
+
33
+ expect(lowClass).toContain("bg-neutral-400")
34
+ expect(lowClass).not.toContain("bg-red")
35
+ })
36
+
37
+ it("keeps explicit risk badge styling unchanged", () => {
38
+ render(
39
+ <ScoreBreakdown
40
+ scoreIntent="risk"
41
+ factors={[{ key: "badge", label: "Badge Factor", score: null, risk: "Low", why: "Risk badge" }]}
42
+ />,
43
+ )
44
+
45
+ expect(screen.getByText("Low").getAttribute("class")).toContain("text-emerald-600")
46
+ })
47
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import React from "react"
3
+ import { render } from "@testing-library/react"
4
+ import { ScoreRing, getScoreColor, getScoreTrackColor } from "../score-ring"
5
+
6
+ function renderRing(score: number, intent: React.ComponentProps<typeof ScoreRing>["intent"]) {
7
+ const { container } = render(<ScoreRing score={score} intent={intent} />)
8
+ const circles = container.querySelectorAll("circle")
9
+ return {
10
+ trackClass: circles[0]?.getAttribute("class") ?? "",
11
+ fillClass: circles[1]?.getAttribute("class") ?? "",
12
+ }
13
+ }
14
+
15
+ describe("ScoreRing intent-aware colors", () => {
16
+ it("preserves positive high-is-good colors", () => {
17
+ expect(getScoreColor(90, 100)).toBe("text-emerald-500")
18
+ expect(getScoreColor(20, 100)).toBe("text-red-500")
19
+
20
+ expect(renderRing(90, "positive").fillClass).toContain("text-emerald-500")
21
+ expect(renderRing(20, "positive").fillClass).toContain("text-red-500")
22
+ })
23
+
24
+ it("uses red/orange/amber/neutral colors for urgency", () => {
25
+ expect(getScoreColor(90, 100, "urgency")).toBe("text-red-600")
26
+ expect(getScoreColor(65, 100, "urgency")).toBe("text-orange-500")
27
+ expect(getScoreColor(45, 100, "urgency")).toBe("text-amber-500")
28
+ expect(getScoreColor(20, 100, "urgency")).toBe("text-neutral-400")
29
+ expect(getScoreTrackColor(20, 100, "urgency")).toBe("text-neutral-400/15")
30
+
31
+ expect(renderRing(90, "urgency").fillClass).toContain("text-red-600")
32
+ expect(renderRing(20, "urgency").fillClass).toContain("text-neutral-400")
33
+ expect(renderRing(20, "urgency").fillClass).not.toContain("text-red")
34
+ })
35
+
36
+ it("uses red for high risk and neutral for low risk", () => {
37
+ expect(getScoreColor(90, 100, "risk")).toBe("text-red-600")
38
+ expect(getScoreColor(20, 100, "risk")).toBe("text-neutral-400")
39
+
40
+ expect(renderRing(90, "risk").fillClass).toContain("text-red-600")
41
+ expect(renderRing(20, "risk").trackClass).toContain("text-neutral-400/15")
42
+ })
43
+ })
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ POSITIVE_SCORE_CLASSES,
4
+ RISK_SCORE_CLASSES,
5
+ URGENCY_SCORE_CLASSES,
6
+ getPositiveLevel,
7
+ getRiskLevel,
8
+ getScoreToneClasses,
9
+ getUrgencyLevel,
10
+ getUrgencyRange,
11
+ } from "../score-semantics"
12
+
13
+ describe("score semantics", () => {
14
+ it.each([
15
+ [0, "Low"],
16
+ [34, "Low"],
17
+ [35, "Medium"],
18
+ [59, "Medium"],
19
+ [60, "High"],
20
+ [79, "High"],
21
+ [80, "Urgent"],
22
+ [100, "Urgent"],
23
+ ] as const)("maps urgency score %i to %s", (score, level) => {
24
+ expect(getUrgencyLevel(score)).toBe(level)
25
+ })
26
+
27
+ it("returns urgency ranges", () => {
28
+ expect(getUrgencyRange("Low")).toBe("0-34")
29
+ expect(getUrgencyRange("Medium")).toBe("35-59")
30
+ expect(getUrgencyRange("High")).toBe("60-79")
31
+ expect(getUrgencyRange("Urgent")).toBe("80-100")
32
+ })
33
+
34
+ it("uses the approved urgency palette", () => {
35
+ expect(URGENCY_SCORE_CLASSES.Urgent).toMatchObject({
36
+ solid: "bg-red-600 text-white",
37
+ outline: "border-red-300 bg-red-50 text-red-800",
38
+ dot: "bg-red-600",
39
+ trigger: "border-red-300 bg-red-50 text-red-800",
40
+ hover: "hover:bg-red-100",
41
+ open: "bg-red-100",
42
+ text: "text-red-600",
43
+ track: "text-red-600/15",
44
+ bar: "bg-red-600",
45
+ })
46
+ expect(URGENCY_SCORE_CLASSES.High.trigger).toBe("border-orange-300 bg-orange-50 text-orange-800")
47
+ expect(URGENCY_SCORE_CLASSES.Medium.dot).toBe("bg-amber-500")
48
+ expect(URGENCY_SCORE_CLASSES.Low).toMatchObject({
49
+ solid: "bg-neutral-300 text-neutral-900",
50
+ outline: "border-neutral-200 bg-neutral-50 text-neutral-700",
51
+ dot: "bg-neutral-400",
52
+ trigger: "border-neutral-200 bg-neutral-50 text-neutral-700",
53
+ hover: "hover:bg-neutral-100",
54
+ open: "bg-neutral-100",
55
+ text: "text-neutral-400",
56
+ track: "text-neutral-400/15",
57
+ bar: "bg-neutral-400",
58
+ })
59
+ })
60
+
61
+ it("maps risk scores without urgency labels", () => {
62
+ expect(getRiskLevel(0)).toBe("Low Risk")
63
+ expect(getRiskLevel(39)).toBe("Low Risk")
64
+ expect(getRiskLevel(40)).toBe("Medium Risk")
65
+ expect(getRiskLevel(69)).toBe("Medium Risk")
66
+ expect(getRiskLevel(70)).toBe("High Risk")
67
+ expect(RISK_SCORE_CLASSES["High Risk"].solid).toBe("bg-red-600 text-white")
68
+ expect(RISK_SCORE_CLASSES["Medium Risk"].dot).toBe("bg-amber-500")
69
+ expect(RISK_SCORE_CLASSES["Low Risk"].trigger).toBe("border-neutral-200 bg-neutral-50 text-neutral-700")
70
+ })
71
+
72
+ it("preserves positive high-is-good semantics", () => {
73
+ expect(getPositiveLevel(39)).toBe("Low")
74
+ expect(getPositiveLevel(40)).toBe("Medium")
75
+ expect(getPositiveLevel(70)).toBe("High")
76
+ expect(POSITIVE_SCORE_CLASSES.High.solid).toBe("bg-emerald-500 text-white")
77
+ expect(POSITIVE_SCORE_CLASSES.Medium.solid).toBe("bg-amber-500 text-white")
78
+ expect(POSITIVE_SCORE_CLASSES.Low.solid).toBe("bg-red-500 text-white")
79
+ })
80
+
81
+ it("returns static urgency classes from the urgency map", () => {
82
+ expect(getScoreToneClasses(0, "urgency")).toBe(URGENCY_SCORE_CLASSES.Low)
83
+ expect(getScoreToneClasses(34, "urgency")).toBe(URGENCY_SCORE_CLASSES.Low)
84
+ expect(getScoreToneClasses(35, "urgency")).toBe(URGENCY_SCORE_CLASSES.Medium)
85
+ expect(getScoreToneClasses(59, "urgency")).toBe(URGENCY_SCORE_CLASSES.Medium)
86
+ expect(getScoreToneClasses(60, "urgency")).toBe(URGENCY_SCORE_CLASSES.High)
87
+ expect(getScoreToneClasses(79, "urgency")).toBe(URGENCY_SCORE_CLASSES.High)
88
+ expect(getScoreToneClasses(80, "urgency")).toBe(URGENCY_SCORE_CLASSES.Urgent)
89
+ expect(getScoreToneClasses(100, "urgency")).toBe(URGENCY_SCORE_CLASSES.Urgent)
90
+ })
91
+
92
+ it("returns static risk classes from risk labels only", () => {
93
+ expect(getScoreToneClasses(39, "risk")).toBe(RISK_SCORE_CLASSES["Low Risk"])
94
+ expect(getScoreToneClasses(40, "risk")).toBe(RISK_SCORE_CLASSES["Medium Risk"])
95
+ expect(getScoreToneClasses(70, "risk")).toBe(RISK_SCORE_CLASSES["High Risk"])
96
+ expect(RISK_SCORE_CLASSES).not.toHaveProperty("Low")
97
+ expect(RISK_SCORE_CLASSES).not.toHaveProperty("Medium")
98
+ expect(RISK_SCORE_CLASSES).not.toHaveProperty("High")
99
+ expect(RISK_SCORE_CLASSES).not.toHaveProperty("Urgent")
100
+ })
101
+
102
+ it("returns static positive classes from high-is-good map", () => {
103
+ expect(getScoreToneClasses(39, "positive")).toBe(POSITIVE_SCORE_CLASSES.Low)
104
+ expect(getScoreToneClasses(40, "positive")).toBe(POSITIVE_SCORE_CLASSES.Medium)
105
+ expect(getScoreToneClasses(70, "positive")).toBe(POSITIVE_SCORE_CLASSES.High)
106
+ })
107
+ })
@@ -92,16 +92,17 @@ describe("SignalPriorityPopover", () => {
92
92
  it("applies urgency-specific default classes to the trigger", () => {
93
93
  render(<SignalPriorityPopover {...defaultProps} urgencyLabel="Urgent" />)
94
94
  const trigger = screen.getByTestId("priority-popover-trigger")
95
- expect(trigger.className).toContain("border-red-200")
95
+ expect(trigger.className).toContain("border-red-300")
96
96
  expect(trigger.className).toContain("bg-red-50")
97
- expect(trigger.className).toContain("text-red-700")
97
+ expect(trigger.className).toContain("text-red-800")
98
98
  })
99
99
 
100
100
  it("applies Low urgency classes", () => {
101
101
  render(<SignalPriorityPopover {...defaultProps} urgencyLabel="Low" score={20} />)
102
102
  const trigger = screen.getByTestId("priority-popover-trigger")
103
- expect(trigger.className).toContain("border-blue-200")
104
- expect(trigger.className).toContain("bg-blue-50")
103
+ expect(trigger.className).toContain("border-neutral-200")
104
+ expect(trigger.className).toContain("bg-neutral-50")
105
+ expect(trigger.className).toContain("text-neutral-700")
105
106
  })
106
107
 
107
108
  it("opens the popover on trigger click and shows content", () => {
@@ -335,8 +336,9 @@ describe("SignalPriorityPopover", () => {
335
336
  it("applies Medium urgency classes correctly", () => {
336
337
  render(<SignalPriorityPopover {...defaultProps} urgencyLabel="Medium" score={45} />)
337
338
  const trigger = screen.getByTestId("priority-popover-trigger")
338
- expect(trigger.className).toContain("border-amber-200")
339
+ expect(trigger.className).toContain("border-amber-300")
339
340
  expect(trigger.className).toContain("bg-amber-50")
341
+ expect(trigger.className).toContain("text-amber-800")
340
342
  expect(trigger.textContent).toContain("Medium Priority")
341
343
  })
342
344
  })
@@ -75,10 +75,12 @@ export interface CasePanelIdentityLink {
75
75
  icon?: React.ReactNode
76
76
  kind?: "icon" | "text"
77
77
  disabled?: boolean
78
+ target?: React.HTMLAttributeAnchorTarget
79
+ rel?: string
78
80
  }
79
81
 
80
82
  export interface CasePanelIdentitySublineProps {
81
- callsign: string
83
+ callsign?: string | null
82
84
  links?: CasePanelIdentityLink[]
83
85
  onCopyCallsign?: (callsign: string) => void
84
86
  copyLabel?: string
@@ -95,9 +97,15 @@ export function CasePanelIdentitySubline({
95
97
  className,
96
98
  }: CasePanelIdentitySublineProps) {
97
99
  const [copied, setCopied] = React.useState(false)
98
- const normalizedCallsign = callsign.startsWith("@") ? callsign : `@${callsign}`
100
+ const trimmedCallsign = callsign?.trim()
101
+ const normalizedCallsign = trimmedCallsign
102
+ ? trimmedCallsign.startsWith("@")
103
+ ? trimmedCallsign
104
+ : `@${trimmedCallsign}`
105
+ : null
99
106
 
100
107
  const handleCopy = React.useCallback(() => {
108
+ if (!normalizedCallsign) return
101
109
  onCopyCallsign?.(normalizedCallsign)
102
110
  setCopied(true)
103
111
  window.setTimeout(() => setCopied(false), 1400)
@@ -105,18 +113,22 @@ export function CasePanelIdentitySubline({
105
113
 
106
114
  return (
107
115
  <div className={cn("mt-[9px] inline-flex flex-wrap items-center gap-[7px] text-[13px] text-muted-foreground", className)}>
108
- <span className="font-mono font-medium tracking-[0.01em] text-gray-700">{normalizedCallsign}</span>
109
- <button
110
- type="button"
111
- onClick={handleCopy}
112
- aria-label={copied ? copiedLabel : copyLabel}
113
- className="inline-flex h-[22px] w-[22px] items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-accent hover:text-foreground"
114
- >
115
- {copied ? <Check className="h-[13px] w-[13px] text-emerald-700" aria-hidden="true" /> : <Copy className="h-[13px] w-[13px]" aria-hidden="true" />}
116
- </button>
116
+ {normalizedCallsign ? (
117
+ <>
118
+ <span className="font-mono font-medium tracking-[0.01em] text-gray-700">{normalizedCallsign}</span>
119
+ <button
120
+ type="button"
121
+ onClick={handleCopy}
122
+ aria-label={copied ? copiedLabel : copyLabel}
123
+ className="inline-flex h-[22px] w-[22px] items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-accent hover:text-foreground"
124
+ >
125
+ {copied ? <Check className="h-[13px] w-[13px] text-emerald-700" aria-hidden="true" /> : <Copy className="h-[13px] w-[13px]" aria-hidden="true" />}
126
+ </button>
127
+ </>
128
+ ) : null}
117
129
  {links.length > 0 ? (
118
130
  <>
119
- <span aria-hidden="true" className="mx-[3px] h-3.5 w-px bg-gray-200" />
131
+ {normalizedCallsign ? <span aria-hidden="true" className="mx-[3px] h-3.5 w-px bg-gray-200" /> : null}
120
132
  <span className="inline-flex items-center gap-1.5">
121
133
  {links.map((link) => (
122
134
  <CasePanelIdentityLinkButton key={link.id} link={link} />
@@ -154,7 +166,13 @@ function CasePanelIdentityLinkButton({ link }: { link: CasePanelIdentityLink })
154
166
  }
155
167
 
156
168
  return (
157
- <a href={link.href} aria-label={link.label} className={className}>
169
+ <a
170
+ href={link.href}
171
+ aria-label={link.label}
172
+ target={link.target ?? "_blank"}
173
+ rel={link.rel ?? "noopener noreferrer"}
174
+ className={className}
175
+ >
158
176
  {content}
159
177
  </a>
160
178
  )
@@ -156,6 +156,7 @@ export interface CasePanelEmailComposerProps extends Omit<React.HTMLAttributes<H
156
156
  sendBarActions?: React.ReactNode
157
157
  signatureControl?: React.ReactNode
158
158
  disabledReason?: React.ReactNode
159
+ showSendBar?: boolean
159
160
  disabled?: boolean
160
161
  sendDisabled?: boolean
161
162
  sendLabel?: React.ReactNode
@@ -193,6 +194,7 @@ export function CasePanelEmailComposer({
193
194
  sendBarActions,
194
195
  signatureControl,
195
196
  disabledReason,
197
+ showSendBar = true,
196
198
  disabled = false,
197
199
  sendDisabled = false,
198
200
  sendLabel = "Send",
@@ -313,28 +315,30 @@ export function CasePanelEmailComposer({
313
315
  {toolbar ?? <div className="text-xs font-medium text-muted-foreground">Toolbar</div>}
314
316
  </div>
315
317
 
316
- <div data-slot="case-panel-email-composer-send-bar" className="flex items-center justify-between gap-3 border-t border-border/70 bg-background px-3 py-3">
317
- <div className="min-w-0 flex-1">
318
- {disabledReason ? (
319
- <div id={disabledReasonId} data-slot="case-panel-email-composer-disabled-reason" className="truncate text-xs text-muted-foreground">
320
- {disabledReason}
321
- </div>
322
- ) : null}
323
- </div>
324
- <div className="flex shrink-0 items-center gap-2">
325
- {sendBarActions}
326
- <button
327
- type="button"
328
- data-slot="case-panel-email-composer-send-button"
329
- aria-describedby={disabledReason ? disabledReasonId : undefined}
330
- disabled={sendIsDisabled}
331
- onClick={handleSendIntent}
332
- className="inline-flex h-8 items-center justify-center rounded-md bg-foreground px-3 text-xs font-semibold text-background shadow-xs transition-colors hover:bg-foreground/90 disabled:pointer-events-none disabled:bg-muted disabled:text-muted-foreground"
333
- >
334
- {sendLabel}
335
- </button>
318
+ {showSendBar ? (
319
+ <div data-slot="case-panel-email-composer-send-bar" className="flex items-center justify-between gap-3 border-t border-border/70 bg-background px-3 py-3">
320
+ <div className="min-w-0 flex-1">
321
+ {disabledReason ? (
322
+ <div id={disabledReasonId} data-slot="case-panel-email-composer-disabled-reason" className="truncate text-xs text-muted-foreground">
323
+ {disabledReason}
324
+ </div>
325
+ ) : null}
326
+ </div>
327
+ <div className="flex shrink-0 items-center gap-2">
328
+ {sendBarActions}
329
+ <button
330
+ type="button"
331
+ data-slot="case-panel-email-composer-send-button"
332
+ aria-describedby={disabledReason ? disabledReasonId : undefined}
333
+ disabled={sendIsDisabled}
334
+ onClick={handleSendIntent}
335
+ className="inline-flex h-8 items-center justify-center rounded-md bg-foreground px-3 text-xs font-semibold text-background shadow-xs transition-colors hover:bg-foreground/90 disabled:pointer-events-none disabled:bg-muted disabled:text-muted-foreground"
336
+ >
337
+ {sendLabel}
338
+ </button>
339
+ </div>
336
340
  </div>
337
- </div>
341
+ ) : null}
338
342
  </div>
339
343
  </div>
340
344
  )
@@ -892,6 +892,7 @@ export function DataTable({
892
892
  title={data.title}
893
893
  description={data.description}
894
894
  score={scoreModal.type === "Risk" ? scoreModal.row.riskScore : scoreModal.row.expansionScore}
895
+ scoreIntent={scoreModal.type === "Risk" ? "risk" : "positive"}
895
896
  whyNow={data.whyNow}
896
897
  evidence={data.evidence}
897
898
  factors={data.factors}
@@ -7,6 +7,7 @@ import { ScoreRing } from "./score-ring"
7
7
  import { ScoreBreakdown, type ScoreFactor } from "./score-breakdown"
8
8
  import { SignalApproval } from "./signal-feedback-inline"
9
9
  import { X } from "lucide-react"
10
+ import type { ScoreIntent } from "./score-semantics"
10
11
 
11
12
  interface ScoreAnalysisModalProps {
12
13
  open: boolean
@@ -26,10 +27,25 @@ interface ScoreAnalysisModalProps {
26
27
  onDismiss?: (reasons: string[], detail: string) => void
27
28
  /** When true, renders as an absolute-positioned inline panel instead of a Radix Sheet portal. Useful when the component is inside a container that should not be escaped. */
28
29
  useInlinePanel?: boolean
30
+ scoreIntent?: ScoreIntent
29
31
  }
30
32
 
31
- function getScoreLabel(score: number, denominator: number) {
33
+ function getScoreLabel(score: number, denominator: number, intent: ScoreIntent = "positive") {
32
34
  const pct = (score / denominator) * 100
35
+
36
+ if (intent === "urgency") {
37
+ if (pct >= 80) return { text: "URGENT", className: "text-red-600" }
38
+ if (pct >= 60) return { text: "HIGH", className: "text-orange-600" }
39
+ if (pct >= 35) return { text: "MEDIUM", className: "text-amber-600" }
40
+ return { text: "LOW", className: "text-neutral-600" }
41
+ }
42
+
43
+ if (intent === "risk") {
44
+ if (pct >= 70) return { text: "High Risk", className: "text-red-600" }
45
+ if (pct >= 40) return { text: "Medium Risk", className: "text-amber-600" }
46
+ return { text: "Low Risk", className: "text-neutral-600" }
47
+ }
48
+
33
49
  if (pct >= 70) return { text: "HIGH", className: "text-emerald-600" }
34
50
  if (pct >= 40) return { text: "MEDIUM", className: "text-amber-600" }
35
51
  return { text: "LOW", className: "text-red-600" }
@@ -52,8 +68,9 @@ function ScoreAnalysisModal({
52
68
  onApproveFeedback,
53
69
  onDismiss,
54
70
  useInlinePanel = false,
71
+ scoreIntent = "positive",
55
72
  }: ScoreAnalysisModalProps) {
56
- const label = getScoreLabel(score, denominator)
73
+ const label = getScoreLabel(score, denominator, scoreIntent)
57
74
 
58
75
  const panelContent = (
59
76
  <SignalApproval.Root
@@ -83,7 +100,7 @@ function ScoreAnalysisModal({
83
100
 
84
101
  <div className="space-y-6">
85
102
  <div className="flex flex-col items-center gap-3">
86
- <ScoreRing score={score} denominator={denominator} size={120} strokeWidth={10} />
103
+ <ScoreRing score={score} denominator={denominator} size={120} strokeWidth={10} intent={scoreIntent} />
87
104
  <Badge variant="outline">
88
105
  {Math.round((score / denominator) * 100)}% Score
89
106
  {" \u2014 "}
@@ -111,7 +128,7 @@ function ScoreAnalysisModal({
111
128
  {factors && factors.length > 0 && (
112
129
  <div>
113
130
  <h3 className="font-semibold mb-2 text-sm">Score Breakdown</h3>
114
- <ScoreBreakdown factors={factors} onFactorFeedback={onFactorFeedback} />
131
+ <ScoreBreakdown factors={factors} onFactorFeedback={onFactorFeedback} scoreIntent={scoreIntent} />
115
132
  </div>
116
133
  )}
117
134
  </div>
@@ -168,5 +185,5 @@ function ScoreAnalysisModal({
168
185
 
169
186
  const ScoreAnalysisPanel = ScoreAnalysisModal
170
187
 
171
- export { ScoreAnalysisModal, ScoreAnalysisPanel }
188
+ export { ScoreAnalysisModal, ScoreAnalysisPanel, getScoreLabel }
172
189
  export type { ScoreAnalysisModalProps }
@@ -3,6 +3,8 @@
3
3
  import * as React from "react"
4
4
  import { ThumbsUp, ThumbsDown } from "lucide-react"
5
5
  import { cn } from "../lib/utils"
6
+ import type { ScoreIntent } from "./score-semantics"
7
+ import { getScoreToneClasses } from "./score-semantics"
6
8
 
7
9
  export interface ScoreFactor {
8
10
  key: string
@@ -12,10 +14,8 @@ export interface ScoreFactor {
12
14
  why: string
13
15
  }
14
16
 
15
- function getFactorBarColor(score: number) {
16
- if (score >= 70) return "bg-emerald-500"
17
- if (score >= 40) return "bg-amber-500"
18
- return "bg-red-500"
17
+ function getFactorBarColor(score: number, intent: ScoreIntent = "positive") {
18
+ return getScoreToneClasses(score, intent).bar
19
19
  }
20
20
 
21
21
  function getRiskBadgeStyle(risk: "Low" | "Medium" | "High") {
@@ -34,6 +34,7 @@ interface ScoreBreakdownProps {
34
34
  onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
35
35
  className?: string
36
36
  initialFeedback?: Record<string, { type: "up" | "down"; detail: string }>
37
+ scoreIntent?: ScoreIntent
37
38
  }
38
39
 
39
40
  function deriveInitialState<T>(
@@ -47,7 +48,7 @@ function deriveInitialState<T>(
47
48
  return Object.fromEntries(filtered.map(([k, v]) => [k, mapFn(v)]))
48
49
  }
49
50
 
50
- function ScoreBreakdown({ factors, onFactorFeedback, className, initialFeedback }: ScoreBreakdownProps) {
51
+ function ScoreBreakdown({ factors, onFactorFeedback, className, initialFeedback, scoreIntent = "positive" }: ScoreBreakdownProps) {
51
52
  const [feedback, setFeedback] = React.useState<Record<string, "up" | "down" | null>>(
52
53
  () => deriveInitialState(initialFeedback, (v) => v.type)
53
54
  )
@@ -170,7 +171,7 @@ function ScoreBreakdown({ factors, onFactorFeedback, className, initialFeedback
170
171
  {factor.score !== null && (
171
172
  <div className="w-full h-1 bg-muted rounded-full overflow-hidden">
172
173
  <div
173
- className={cn("h-full rounded-full", getFactorBarColor(factor.score))}
174
+ className={cn("h-full rounded-full", getFactorBarColor(factor.score, scoreIntent))}
174
175
  style={{ width: `${factor.score}%` }}
175
176
  />
176
177
  </div>
@@ -1,18 +1,14 @@
1
1
  import * as React from "react"
2
2
  import { cn } from "../lib/utils"
3
+ import type { ScoreIntent } from "./score-semantics"
4
+ import { getScoreToneClasses } from "./score-semantics"
3
5
 
4
- function getScoreColor(score: number, denominator: number) {
5
- const pct = (score / denominator) * 100
6
- if (pct >= 70) return "text-emerald-500"
7
- if (pct >= 40) return "text-amber-500"
8
- return "text-red-500"
6
+ function getScoreColor(score: number, denominator: number, intent: ScoreIntent = "positive") {
7
+ return getScoreToneClasses((score / denominator) * 100, intent).text
9
8
  }
10
9
 
11
- function getScoreTrackColor(score: number, denominator: number) {
12
- const pct = (score / denominator) * 100
13
- if (pct >= 70) return "text-emerald-500/15"
14
- if (pct >= 40) return "text-amber-500/15"
15
- return "text-red-500/15"
10
+ function getScoreTrackColor(score: number, denominator: number, intent: ScoreIntent = "positive") {
11
+ return getScoreToneClasses((score / denominator) * 100, intent).track
16
12
  }
17
13
 
18
14
  interface ScoreRingProps {
@@ -22,6 +18,7 @@ interface ScoreRingProps {
22
18
  strokeWidth?: number
23
19
  className?: string
24
20
  showLabel?: boolean
21
+ intent?: ScoreIntent
25
22
  }
26
23
 
27
24
  function ScoreRing({
@@ -31,6 +28,7 @@ function ScoreRing({
31
28
  strokeWidth = 10,
32
29
  className,
33
30
  showLabel = true,
31
+ intent = "positive",
34
32
  }: ScoreRingProps) {
35
33
  const radius = (size - strokeWidth) / 2
36
34
  const circumference = 2 * Math.PI * radius
@@ -53,7 +51,7 @@ function ScoreRing({
53
51
  fill="none"
54
52
  stroke="currentColor"
55
53
  strokeWidth={strokeWidth}
56
- className={getScoreTrackColor(score, denominator)}
54
+ className={getScoreTrackColor(score, denominator, intent)}
57
55
  />
58
56
  {/* Fill */}
59
57
  <circle
@@ -66,7 +64,7 @@ function ScoreRing({
66
64
  strokeLinecap="round"
67
65
  strokeDasharray={circumference}
68
66
  strokeDashoffset={offset}
69
- className={cn("transition-all duration-500", getScoreColor(score, denominator))}
67
+ className={cn("transition-all duration-500", getScoreColor(score, denominator, intent))}
70
68
  />
71
69
  </svg>
72
70
  {showLabel && (
@@ -83,5 +81,5 @@ function ScoreRing({
83
81
  )
84
82
  }
85
83
 
86
- export { ScoreRing, getScoreColor }
84
+ export { ScoreRing, getScoreColor, getScoreTrackColor }
87
85
  export type { ScoreRingProps }