@handled-ai/design-system 0.18.18 → 0.18.20
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/charts/chart.d.ts +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/data-table.js +1 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/pill.d.ts +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 +30 -6
- package/dist/components/score-ring.js.map +1 -1
- package/dist/components/score-semantics.d.ts +24 -0
- package/dist/components/score-semantics.js +143 -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/components/tabs.d.ts +1 -1
- package/dist/components/virtualized-data-table.js +13 -13
- package/dist/components/virtualized-data-table.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 -5
- 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 +101 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +7 -5
- package/src/components/__tests__/virtualized-data-table.test.tsx +53 -0
- 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 +36 -5
- package/src/components/score-semantics.ts +154 -0
- package/src/components/score-why-chips.tsx +12 -23
- package/src/components/signal-priority-popover.tsx +21 -21
- package/src/components/virtualized-data-table.tsx +25 -19
- package/src/index.ts +1 -0
|
@@ -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,101 @@
|
|
|
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).toEqual({
|
|
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
|
+
})
|
|
43
|
+
expect(URGENCY_SCORE_CLASSES.High.trigger).toBe("border-orange-300 bg-orange-50 text-orange-800")
|
|
44
|
+
expect(URGENCY_SCORE_CLASSES.Medium.dot).toBe("bg-amber-500")
|
|
45
|
+
expect(URGENCY_SCORE_CLASSES.Low).toEqual({
|
|
46
|
+
solid: "bg-neutral-300 text-neutral-900",
|
|
47
|
+
outline: "border-neutral-200 bg-neutral-50 text-neutral-700",
|
|
48
|
+
dot: "bg-neutral-400",
|
|
49
|
+
trigger: "border-neutral-200 bg-neutral-50 text-neutral-700",
|
|
50
|
+
hover: "hover:bg-neutral-100",
|
|
51
|
+
open: "bg-neutral-100",
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("maps risk scores without urgency labels", () => {
|
|
56
|
+
expect(getRiskLevel(0)).toBe("Low Risk")
|
|
57
|
+
expect(getRiskLevel(39)).toBe("Low Risk")
|
|
58
|
+
expect(getRiskLevel(40)).toBe("Medium Risk")
|
|
59
|
+
expect(getRiskLevel(69)).toBe("Medium Risk")
|
|
60
|
+
expect(getRiskLevel(70)).toBe("High Risk")
|
|
61
|
+
expect(RISK_SCORE_CLASSES["High Risk"].solid).toBe("bg-red-600 text-white")
|
|
62
|
+
expect(RISK_SCORE_CLASSES["Medium Risk"].dot).toBe("bg-orange-500")
|
|
63
|
+
expect(RISK_SCORE_CLASSES["Low Risk"].trigger).toBe("border-neutral-200 bg-neutral-50 text-neutral-700")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("preserves positive high-is-good semantics", () => {
|
|
67
|
+
expect(getPositiveLevel(39)).toBe("Low")
|
|
68
|
+
expect(getPositiveLevel(40)).toBe("Medium")
|
|
69
|
+
expect(getPositiveLevel(70)).toBe("High")
|
|
70
|
+
expect(POSITIVE_SCORE_CLASSES.High.solid).toBe("bg-emerald-500 text-white")
|
|
71
|
+
expect(POSITIVE_SCORE_CLASSES.Medium.solid).toBe("bg-amber-500 text-white")
|
|
72
|
+
expect(POSITIVE_SCORE_CLASSES.Low.solid).toBe("bg-red-500 text-white")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("returns static urgency classes from the urgency map", () => {
|
|
76
|
+
expect(getScoreToneClasses(0, "urgency")).toBe(URGENCY_SCORE_CLASSES.Low)
|
|
77
|
+
expect(getScoreToneClasses(34, "urgency")).toBe(URGENCY_SCORE_CLASSES.Low)
|
|
78
|
+
expect(getScoreToneClasses(35, "urgency")).toBe(URGENCY_SCORE_CLASSES.Medium)
|
|
79
|
+
expect(getScoreToneClasses(59, "urgency")).toBe(URGENCY_SCORE_CLASSES.Medium)
|
|
80
|
+
expect(getScoreToneClasses(60, "urgency")).toBe(URGENCY_SCORE_CLASSES.High)
|
|
81
|
+
expect(getScoreToneClasses(79, "urgency")).toBe(URGENCY_SCORE_CLASSES.High)
|
|
82
|
+
expect(getScoreToneClasses(80, "urgency")).toBe(URGENCY_SCORE_CLASSES.Urgent)
|
|
83
|
+
expect(getScoreToneClasses(100, "urgency")).toBe(URGENCY_SCORE_CLASSES.Urgent)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("returns static risk classes from risk labels only", () => {
|
|
87
|
+
expect(getScoreToneClasses(39, "risk")).toBe(RISK_SCORE_CLASSES["Low Risk"])
|
|
88
|
+
expect(getScoreToneClasses(40, "risk")).toBe(RISK_SCORE_CLASSES["Medium Risk"])
|
|
89
|
+
expect(getScoreToneClasses(70, "risk")).toBe(RISK_SCORE_CLASSES["High Risk"])
|
|
90
|
+
expect(RISK_SCORE_CLASSES).not.toHaveProperty("Low")
|
|
91
|
+
expect(RISK_SCORE_CLASSES).not.toHaveProperty("Medium")
|
|
92
|
+
expect(RISK_SCORE_CLASSES).not.toHaveProperty("High")
|
|
93
|
+
expect(RISK_SCORE_CLASSES).not.toHaveProperty("Urgent")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("returns static positive classes from high-is-good map", () => {
|
|
97
|
+
expect(getScoreToneClasses(39, "positive")).toBe(POSITIVE_SCORE_CLASSES.Low)
|
|
98
|
+
expect(getScoreToneClasses(40, "positive")).toBe(POSITIVE_SCORE_CLASSES.Medium)
|
|
99
|
+
expect(getScoreToneClasses(70, "positive")).toBe(POSITIVE_SCORE_CLASSES.High)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -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
|
})
|
|
@@ -554,3 +554,56 @@ describe("VirtualizedDataTable — dropdown gating", () => {
|
|
|
554
554
|
expect(triggers.length).toBe(1);
|
|
555
555
|
});
|
|
556
556
|
});
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
// ─── Group 8: Overflow-safe header labels ───────────────────────────────────
|
|
560
|
+
|
|
561
|
+
describe("VirtualizedDataTable — overflow-safe header labels", () => {
|
|
562
|
+
it("renders a long sortable header with min-width and truncation structure", () => {
|
|
563
|
+
const columns: ColumnDef<TestRow, unknown>[] = [
|
|
564
|
+
{
|
|
565
|
+
accessorKey: "name",
|
|
566
|
+
header:
|
|
567
|
+
"Extremely Long Relationship Manager Ownership Header That Should Truncate",
|
|
568
|
+
size: 120,
|
|
569
|
+
meta: { sortKey: "name" },
|
|
570
|
+
},
|
|
571
|
+
];
|
|
572
|
+
|
|
573
|
+
const { container } = render(
|
|
574
|
+
<VirtualizedDataTable
|
|
575
|
+
columns={columns}
|
|
576
|
+
data={testData}
|
|
577
|
+
height={300}
|
|
578
|
+
onColumnSort={vi.fn()}
|
|
579
|
+
activeSortColumn={null}
|
|
580
|
+
/>,
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const header = container.querySelector('[role="columnheader"]')!;
|
|
584
|
+
expect(header.className).toContain("min-w-0");
|
|
585
|
+
expect(header.className).toContain("items-center");
|
|
586
|
+
|
|
587
|
+
const sortButton = Array.from(header.querySelectorAll("button")).find(
|
|
588
|
+
(button) => button.getAttribute("aria-label") !== "Column actions",
|
|
589
|
+
)!;
|
|
590
|
+
expect(sortButton.className).toContain("min-w-0");
|
|
591
|
+
expect(sortButton.className).toContain("flex-1");
|
|
592
|
+
expect(sortButton.className).toContain("items-center");
|
|
593
|
+
|
|
594
|
+
const label = sortButton.querySelector("span")!;
|
|
595
|
+
expect(label.textContent).toBe(
|
|
596
|
+
"Extremely Long Relationship Manager Ownership Header That Should Truncate",
|
|
597
|
+
);
|
|
598
|
+
expect(label.className).toContain("min-w-0");
|
|
599
|
+
expect(label.className).toContain("truncate");
|
|
600
|
+
|
|
601
|
+
const sortIcon = sortButton.querySelector("svg")!;
|
|
602
|
+
expect(sortIcon.getAttribute("class")).toContain("shrink-0");
|
|
603
|
+
|
|
604
|
+
const actionsButton = header.querySelector(
|
|
605
|
+
'button[aria-label="Column actions"]',
|
|
606
|
+
)!;
|
|
607
|
+
expect(actionsButton.className).toContain("shrink-0");
|
|
608
|
+
});
|
|
609
|
+
});
|
|
@@ -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 { getScoreColor } from "./score-ring"
|
|
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 getScoreColor(score, 100, intent).replace("text-", "bg-")
|
|
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,15 +1,44 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
2
|
import { cn } from "../lib/utils"
|
|
3
|
+
import type { ScoreIntent } from "./score-semantics"
|
|
3
4
|
|
|
4
|
-
function getScoreColor(score: number, denominator: number) {
|
|
5
|
+
function getScoreColor(score: number, denominator: number, intent: ScoreIntent = "positive") {
|
|
5
6
|
const pct = (score / denominator) * 100
|
|
7
|
+
|
|
8
|
+
if (intent === "urgency") {
|
|
9
|
+
if (pct >= 80) return "text-red-600"
|
|
10
|
+
if (pct >= 60) return "text-orange-500"
|
|
11
|
+
if (pct >= 35) return "text-amber-500"
|
|
12
|
+
return "text-neutral-400"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (intent === "risk") {
|
|
16
|
+
if (pct >= 70) return "text-red-600"
|
|
17
|
+
if (pct >= 40) return "text-amber-500"
|
|
18
|
+
return "text-neutral-400"
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
if (pct >= 70) return "text-emerald-500"
|
|
7
22
|
if (pct >= 40) return "text-amber-500"
|
|
8
23
|
return "text-red-500"
|
|
9
24
|
}
|
|
10
25
|
|
|
11
|
-
function getScoreTrackColor(score: number, denominator: number) {
|
|
26
|
+
function getScoreTrackColor(score: number, denominator: number, intent: ScoreIntent = "positive") {
|
|
12
27
|
const pct = (score / denominator) * 100
|
|
28
|
+
|
|
29
|
+
if (intent === "urgency") {
|
|
30
|
+
if (pct >= 80) return "text-red-600/15"
|
|
31
|
+
if (pct >= 60) return "text-orange-500/15"
|
|
32
|
+
if (pct >= 35) return "text-amber-500/15"
|
|
33
|
+
return "text-neutral-400/15"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (intent === "risk") {
|
|
37
|
+
if (pct >= 70) return "text-red-600/15"
|
|
38
|
+
if (pct >= 40) return "text-amber-500/15"
|
|
39
|
+
return "text-neutral-400/15"
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
if (pct >= 70) return "text-emerald-500/15"
|
|
14
43
|
if (pct >= 40) return "text-amber-500/15"
|
|
15
44
|
return "text-red-500/15"
|
|
@@ -22,6 +51,7 @@ interface ScoreRingProps {
|
|
|
22
51
|
strokeWidth?: number
|
|
23
52
|
className?: string
|
|
24
53
|
showLabel?: boolean
|
|
54
|
+
intent?: ScoreIntent
|
|
25
55
|
}
|
|
26
56
|
|
|
27
57
|
function ScoreRing({
|
|
@@ -31,6 +61,7 @@ function ScoreRing({
|
|
|
31
61
|
strokeWidth = 10,
|
|
32
62
|
className,
|
|
33
63
|
showLabel = true,
|
|
64
|
+
intent = "positive",
|
|
34
65
|
}: ScoreRingProps) {
|
|
35
66
|
const radius = (size - strokeWidth) / 2
|
|
36
67
|
const circumference = 2 * Math.PI * radius
|
|
@@ -53,7 +84,7 @@ function ScoreRing({
|
|
|
53
84
|
fill="none"
|
|
54
85
|
stroke="currentColor"
|
|
55
86
|
strokeWidth={strokeWidth}
|
|
56
|
-
className={getScoreTrackColor(score, denominator)}
|
|
87
|
+
className={getScoreTrackColor(score, denominator, intent)}
|
|
57
88
|
/>
|
|
58
89
|
{/* Fill */}
|
|
59
90
|
<circle
|
|
@@ -66,7 +97,7 @@ function ScoreRing({
|
|
|
66
97
|
strokeLinecap="round"
|
|
67
98
|
strokeDasharray={circumference}
|
|
68
99
|
strokeDashoffset={offset}
|
|
69
|
-
className={cn("transition-all duration-500", getScoreColor(score, denominator))}
|
|
100
|
+
className={cn("transition-all duration-500", getScoreColor(score, denominator, intent))}
|
|
70
101
|
/>
|
|
71
102
|
</svg>
|
|
72
103
|
{showLabel && (
|
|
@@ -83,5 +114,5 @@ function ScoreRing({
|
|
|
83
114
|
)
|
|
84
115
|
}
|
|
85
116
|
|
|
86
|
-
export { ScoreRing, getScoreColor }
|
|
117
|
+
export { ScoreRing, getScoreColor, getScoreTrackColor }
|
|
87
118
|
export type { ScoreRingProps }
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export type ScoreIntent = "urgency" | "risk" | "positive"
|
|
2
|
+
|
|
3
|
+
export type UrgencyLevel = "Low" | "Medium" | "High" | "Urgent"
|
|
4
|
+
export type RiskLevel = "Low Risk" | "Medium Risk" | "High Risk"
|
|
5
|
+
export type PositiveLevel = "Low" | "Medium" | "High"
|
|
6
|
+
|
|
7
|
+
export type ScoreSemanticClasses = {
|
|
8
|
+
solid: string
|
|
9
|
+
outline: string
|
|
10
|
+
dot: string
|
|
11
|
+
trigger: string
|
|
12
|
+
hover: string
|
|
13
|
+
open: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const URGENCY_SCORE_CLASSES: Record<UrgencyLevel, ScoreSemanticClasses> = {
|
|
17
|
+
Urgent: {
|
|
18
|
+
solid: "bg-red-600 text-white",
|
|
19
|
+
outline: "border-red-300 bg-red-50 text-red-800",
|
|
20
|
+
dot: "bg-red-600",
|
|
21
|
+
trigger: "border-red-300 bg-red-50 text-red-800",
|
|
22
|
+
hover: "hover:bg-red-100",
|
|
23
|
+
open: "bg-red-100",
|
|
24
|
+
},
|
|
25
|
+
High: {
|
|
26
|
+
solid: "bg-orange-500 text-white",
|
|
27
|
+
outline: "border-orange-300 bg-orange-50 text-orange-800",
|
|
28
|
+
dot: "bg-orange-500",
|
|
29
|
+
trigger: "border-orange-300 bg-orange-50 text-orange-800",
|
|
30
|
+
hover: "hover:bg-orange-100",
|
|
31
|
+
open: "bg-orange-100",
|
|
32
|
+
},
|
|
33
|
+
Medium: {
|
|
34
|
+
solid: "bg-amber-500 text-white",
|
|
35
|
+
outline: "border-amber-300 bg-amber-50 text-amber-800",
|
|
36
|
+
dot: "bg-amber-500",
|
|
37
|
+
trigger: "border-amber-300 bg-amber-50 text-amber-800",
|
|
38
|
+
hover: "hover:bg-amber-100",
|
|
39
|
+
open: "bg-amber-100",
|
|
40
|
+
},
|
|
41
|
+
Low: {
|
|
42
|
+
solid: "bg-neutral-300 text-neutral-900",
|
|
43
|
+
outline: "border-neutral-200 bg-neutral-50 text-neutral-700",
|
|
44
|
+
dot: "bg-neutral-400",
|
|
45
|
+
trigger: "border-neutral-200 bg-neutral-50 text-neutral-700",
|
|
46
|
+
hover: "hover:bg-neutral-100",
|
|
47
|
+
open: "bg-neutral-100",
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const RISK_SCORE_CLASSES: Record<RiskLevel, ScoreSemanticClasses> = {
|
|
52
|
+
"High Risk": {
|
|
53
|
+
solid: "bg-red-600 text-white",
|
|
54
|
+
outline: "border-red-300 bg-red-50 text-red-800",
|
|
55
|
+
dot: "bg-red-600",
|
|
56
|
+
trigger: "border-red-300 bg-red-50 text-red-800",
|
|
57
|
+
hover: "hover:bg-red-100",
|
|
58
|
+
open: "bg-red-100",
|
|
59
|
+
},
|
|
60
|
+
"Medium Risk": {
|
|
61
|
+
solid: "bg-orange-500 text-white",
|
|
62
|
+
outline: "border-orange-300 bg-orange-50 text-orange-800",
|
|
63
|
+
dot: "bg-orange-500",
|
|
64
|
+
trigger: "border-orange-300 bg-orange-50 text-orange-800",
|
|
65
|
+
hover: "hover:bg-orange-100",
|
|
66
|
+
open: "bg-orange-100",
|
|
67
|
+
},
|
|
68
|
+
"Low Risk": {
|
|
69
|
+
solid: "bg-neutral-300 text-neutral-900",
|
|
70
|
+
outline: "border-neutral-200 bg-neutral-50 text-neutral-700",
|
|
71
|
+
dot: "bg-neutral-400",
|
|
72
|
+
trigger: "border-neutral-200 bg-neutral-50 text-neutral-700",
|
|
73
|
+
hover: "hover:bg-neutral-100",
|
|
74
|
+
open: "bg-neutral-100",
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const POSITIVE_SCORE_CLASSES: Record<PositiveLevel, ScoreSemanticClasses> = {
|
|
79
|
+
High: {
|
|
80
|
+
solid: "bg-emerald-500 text-white",
|
|
81
|
+
outline: "border-emerald-300 bg-emerald-50 text-emerald-800",
|
|
82
|
+
dot: "bg-emerald-500",
|
|
83
|
+
trigger: "border-emerald-300 bg-emerald-50 text-emerald-800",
|
|
84
|
+
hover: "hover:bg-emerald-100",
|
|
85
|
+
open: "bg-emerald-100",
|
|
86
|
+
},
|
|
87
|
+
Medium: {
|
|
88
|
+
solid: "bg-amber-500 text-white",
|
|
89
|
+
outline: "border-amber-300 bg-amber-50 text-amber-800",
|
|
90
|
+
dot: "bg-amber-500",
|
|
91
|
+
trigger: "border-amber-300 bg-amber-50 text-amber-800",
|
|
92
|
+
hover: "hover:bg-amber-100",
|
|
93
|
+
open: "bg-amber-100",
|
|
94
|
+
},
|
|
95
|
+
Low: {
|
|
96
|
+
solid: "bg-red-500 text-white",
|
|
97
|
+
outline: "border-red-300 bg-red-50 text-red-800",
|
|
98
|
+
dot: "bg-red-500",
|
|
99
|
+
trigger: "border-red-300 bg-red-50 text-red-800",
|
|
100
|
+
hover: "hover:bg-red-100",
|
|
101
|
+
open: "bg-red-100",
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const SCORE_TONE_CLASSES: Record<string, string> = {
|
|
106
|
+
alert: "bg-red-50 text-red-600",
|
|
107
|
+
warn: "bg-amber-50 text-amber-600",
|
|
108
|
+
info: "bg-blue-50 text-blue-600",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const DEFAULT_SCORE_TONE_CLASS = "bg-muted text-muted-foreground"
|
|
112
|
+
|
|
113
|
+
export function getUrgencyLevel(score: number): UrgencyLevel {
|
|
114
|
+
if (score >= 80) return "Urgent"
|
|
115
|
+
if (score >= 60) return "High"
|
|
116
|
+
if (score >= 35) return "Medium"
|
|
117
|
+
return "Low"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getUrgencyRange(label: UrgencyLevel): string {
|
|
121
|
+
switch (label) {
|
|
122
|
+
case "Urgent":
|
|
123
|
+
return "80-100"
|
|
124
|
+
case "High":
|
|
125
|
+
return "60-79"
|
|
126
|
+
case "Medium":
|
|
127
|
+
return "35-59"
|
|
128
|
+
case "Low":
|
|
129
|
+
return "0-34"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getRiskLevel(score: number): RiskLevel {
|
|
134
|
+
if (score >= 70) return "High Risk"
|
|
135
|
+
if (score >= 40) return "Medium Risk"
|
|
136
|
+
return "Low Risk"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getPositiveLevel(score: number): PositiveLevel {
|
|
140
|
+
if (score >= 70) return "High"
|
|
141
|
+
if (score >= 40) return "Medium"
|
|
142
|
+
return "Low"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getScoreToneClasses(score: number, intent: ScoreIntent): ScoreSemanticClasses {
|
|
146
|
+
switch (intent) {
|
|
147
|
+
case "urgency":
|
|
148
|
+
return URGENCY_SCORE_CLASSES[getUrgencyLevel(score)]
|
|
149
|
+
case "risk":
|
|
150
|
+
return RISK_SCORE_CLASSES[getRiskLevel(score)]
|
|
151
|
+
case "positive":
|
|
152
|
+
return POSITIVE_SCORE_CLASSES[getPositiveLevel(score)]
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -17,6 +17,12 @@ import type { LucideIcon } from "lucide-react"
|
|
|
17
17
|
import { FeedbackFooter } from "./feedback-primitives"
|
|
18
18
|
import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "./feedback-primitives"
|
|
19
19
|
import { cn } from "../lib/utils"
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_SCORE_TONE_CLASS,
|
|
22
|
+
SCORE_TONE_CLASSES,
|
|
23
|
+
getUrgencyLevel,
|
|
24
|
+
getUrgencyRange,
|
|
25
|
+
} from "./score-semantics"
|
|
20
26
|
import type {
|
|
21
27
|
QueueItem,
|
|
22
28
|
SignalScoreData,
|
|
@@ -33,24 +39,11 @@ export function getSignalScoreUrgencyLabel(
|
|
|
33
39
|
score: number,
|
|
34
40
|
providedLabel?: SignalScoreUrgencyLabel,
|
|
35
41
|
): SignalScoreUrgencyLabel {
|
|
36
|
-
|
|
37
|
-
if (score >= 80) return "Urgent"
|
|
38
|
-
if (score >= 60) return "High"
|
|
39
|
-
if (score >= 35) return "Medium"
|
|
40
|
-
return "Low"
|
|
42
|
+
return providedLabel ?? getUrgencyLevel(score)
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export function scoreRangeForUrgency(label: SignalScoreUrgencyLabel): string {
|
|
44
|
-
|
|
45
|
-
case "Urgent":
|
|
46
|
-
return "80-100"
|
|
47
|
-
case "High":
|
|
48
|
-
return "60-79"
|
|
49
|
-
case "Medium":
|
|
50
|
-
return "35-59"
|
|
51
|
-
case "Low":
|
|
52
|
-
return "0-34"
|
|
53
|
-
}
|
|
46
|
+
return getUrgencyRange(label)
|
|
54
47
|
}
|
|
55
48
|
|
|
56
49
|
function makeDomId(...parts: Array<string | undefined>): string {
|
|
@@ -107,15 +100,11 @@ function resolveIcon(iconName?: string): LucideIcon {
|
|
|
107
100
|
// Static tone class maps (REQUIRED for Tailwind v4 source scanning)
|
|
108
101
|
// ---------------------------------------------------------------------------
|
|
109
102
|
|
|
110
|
-
/** Shared tone-to-class map. Re-exported for
|
|
111
|
-
export const SIGNAL_TONE_CLASSES: Record<string, string> =
|
|
112
|
-
alert: "bg-red-50 text-red-600",
|
|
113
|
-
warn: "bg-amber-50 text-amber-600",
|
|
114
|
-
info: "bg-blue-50 text-blue-600",
|
|
115
|
-
}
|
|
103
|
+
/** Shared tone-to-class map. Re-exported for backward compatibility. */
|
|
104
|
+
export const SIGNAL_TONE_CLASSES: Record<string, string> = SCORE_TONE_CLASSES
|
|
116
105
|
|
|
117
|
-
/** Default tone for missing/unknown tone values */
|
|
118
|
-
export const DEFAULT_TONE_CLASS =
|
|
106
|
+
/** Default tone for missing/unknown tone values. Re-exported for backward compatibility. */
|
|
107
|
+
export const DEFAULT_TONE_CLASS = DEFAULT_SCORE_TONE_CLASS
|
|
119
108
|
|
|
120
109
|
// ---------------------------------------------------------------------------
|
|
121
110
|
// Em-dash fallback for missing slot data
|