@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.
- package/dist/components/case-panel-detail.d.ts +3 -1
- package/dist/components/case-panel-detail.js +29 -15
- package/dist/components/case-panel-detail.js.map +1 -1
- package/dist/components/case-panel-email-composer.d.ts +2 -1
- package/dist/components/case-panel-email-composer.js +4 -2
- package/dist/components/case-panel-email-composer.js.map +1 -1
- package/dist/components/data-table.js +1 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/score-analysis-modal.d.ts +8 -2
- package/dist/components/score-analysis-modal.js +19 -6
- package/dist/components/score-analysis-modal.js.map +1 -1
- package/dist/components/score-breakdown.d.ts +3 -1
- package/dist/components/score-breakdown.js +5 -6
- package/dist/components/score-breakdown.js.map +1 -1
- package/dist/components/score-ring.d.ts +6 -3
- package/dist/components/score-ring.js +11 -14
- package/dist/components/score-ring.js.map +1 -1
- package/dist/components/score-semantics.d.ts +27 -0
- package/dist/components/score-semantics.js +173 -0
- package/dist/components/score-semantics.js.map +1 -0
- package/dist/components/score-why-chips.d.ts +3 -2
- package/dist/components/score-why-chips.js +10 -21
- package/dist/components/score-why-chips.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -0
- package/dist/components/signal-priority-popover.js +20 -20
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -0
- package/dist/prototype/prototype-accounts-view.d.ts +1 -0
- package/dist/prototype/prototype-admin-view.d.ts +1 -0
- package/dist/prototype/prototype-config.d.ts +1 -0
- package/dist/prototype/prototype-inbox-view.d.ts +1 -0
- package/dist/prototype/prototype-insights-view.d.ts +1 -0
- package/dist/prototype/prototype-shell.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/__tests__/case-panel-detail.test.tsx +17 -1
- package/src/components/__tests__/case-panel-email-composer.test.tsx +7 -0
- package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +4 -1
- package/src/components/__tests__/score-analysis-modal.test.tsx +55 -0
- package/src/components/__tests__/score-breakdown-intent.test.tsx +47 -0
- package/src/components/__tests__/score-ring.test.tsx +43 -0
- package/src/components/__tests__/score-semantics.test.ts +107 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +7 -5
- package/src/components/case-panel-detail.tsx +31 -13
- package/src/components/case-panel-email-composer.tsx +25 -21
- package/src/components/data-table.tsx +1 -0
- package/src/components/score-analysis-modal.tsx +22 -5
- package/src/components/score-breakdown.tsx +7 -6
- package/src/components/score-ring.tsx +11 -13
- package/src/components/score-semantics.ts +187 -0
- package/src/components/score-why-chips.tsx +12 -23
- package/src/components/signal-priority-popover.tsx +21 -21
- 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-
|
|
95
|
+
expect(trigger.className).toContain("border-red-300")
|
|
96
96
|
expect(trigger.className).toContain("bg-red-50")
|
|
97
|
-
expect(trigger.className).toContain("text-red-
|
|
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-
|
|
104
|
-
expect(trigger.className).toContain("bg-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
317
|
-
<div
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
{
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|