@handled-ai/design-system 0.18.36 → 0.18.38

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 (42) hide show
  1. package/dist/charts/chart.d.ts +1 -1
  2. package/dist/components/draft-feedback-inline.d.ts +1 -1
  3. package/dist/components/draft-feedback-inline.js +10 -10
  4. package/dist/components/draft-feedback-inline.js.map +1 -1
  5. package/dist/components/email-composer-row.d.ts +11 -0
  6. package/dist/components/email-composer-row.js +82 -0
  7. package/dist/components/email-composer-row.js.map +1 -0
  8. package/dist/components/email-preview-card.d.ts +17 -0
  9. package/dist/components/email-preview-card.js +71 -0
  10. package/dist/components/email-preview-card.js.map +1 -0
  11. package/dist/components/email-recipient-field.d.ts +26 -0
  12. package/dist/components/email-recipient-field.js +403 -0
  13. package/dist/components/email-recipient-field.js.map +1 -0
  14. package/dist/components/email-send-bar.d.ts +22 -0
  15. package/dist/components/email-send-bar.js +66 -0
  16. package/dist/components/email-send-bar.js.map +1 -0
  17. package/dist/components/entity-panel.d.ts +1 -15
  18. package/dist/components/entity-panel.js +1 -74
  19. package/dist/components/entity-panel.js.map +1 -1
  20. package/dist/components/score-feedback.js +6 -6
  21. package/dist/components/score-feedback.js.map +1 -1
  22. package/dist/components/suggested-actions.js +5 -17
  23. package/dist/components/suggested-actions.js.map +1 -1
  24. package/dist/index.d.ts +5 -1
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/__tests__/email-composer-row.test.tsx +51 -0
  29. package/src/components/__tests__/email-preview-card.test.tsx +62 -0
  30. package/src/components/__tests__/email-recipient-field.test.tsx +256 -0
  31. package/src/components/__tests__/email-send-bar.test.tsx +80 -0
  32. package/src/components/draft-feedback-inline.tsx +13 -13
  33. package/src/components/email-composer-row.tsx +47 -0
  34. package/src/components/email-preview-card.tsx +94 -0
  35. package/src/components/email-recipient-field.tsx +461 -0
  36. package/src/components/email-send-bar.tsx +95 -0
  37. package/src/components/entity-panel.tsx +0 -117
  38. package/src/components/score-feedback.tsx +7 -7
  39. package/src/components/suggested-actions.tsx +5 -19
  40. package/src/index.ts +4 -0
  41. package/src/components/__tests__/draft-feedback-inline.test.tsx +0 -72
  42. package/src/components/__tests__/suggested-actions-feedback-header.test.tsx +0 -86
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.18.36",
3
+ "version": "0.18.38",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, afterEach } from "vitest"
2
+ import React from "react"
3
+ import { render, screen, cleanup } from "@testing-library/react"
4
+
5
+ import { EmailComposerRow } from "../email-composer-row"
6
+
7
+ afterEach(() => {
8
+ cleanup()
9
+ })
10
+
11
+ describe("EmailComposerRow", () => {
12
+ it("renders the label and content", () => {
13
+ render(
14
+ <EmailComposerRow label="To">
15
+ <span>content here</span>
16
+ </EmailComposerRow>,
17
+ )
18
+
19
+ expect(screen.getByText("To")).toBeTruthy()
20
+ expect(screen.getByText("content here")).toBeTruthy()
21
+ })
22
+
23
+ it("applies amber state classes", () => {
24
+ const { container } = render(
25
+ <EmailComposerRow label="To" state="amber">
26
+ <span>body</span>
27
+ </EmailComposerRow>,
28
+ )
29
+
30
+ const row = container.firstChild as HTMLElement
31
+ expect(row.className).toContain("bg-amber-50/35")
32
+ expect(row.className).toContain("border-amber-200/80")
33
+
34
+ const label = screen.getByText("To")
35
+ expect(label.className).toContain("text-amber-700")
36
+ })
37
+
38
+ it("applies alignStart classes", () => {
39
+ const { container } = render(
40
+ <EmailComposerRow label="To" alignStart>
41
+ <span>body</span>
42
+ </EmailComposerRow>,
43
+ )
44
+
45
+ const row = container.firstChild as HTMLElement
46
+ expect(row.className).toContain("items-start")
47
+
48
+ const label = screen.getByText("To")
49
+ expect(label.className).toContain("pt-[7px]")
50
+ })
51
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, afterEach } from "vitest"
2
+ import React from "react"
3
+ import { render, screen, cleanup } from "@testing-library/react"
4
+
5
+ import { EmailPreviewCard } from "../email-preview-card"
6
+
7
+ afterEach(() => {
8
+ cleanup()
9
+ })
10
+
11
+ const from = { name: "Cory Pitt", email: "cory@withhandled.com" }
12
+
13
+ describe("EmailPreviewCard", () => {
14
+ it("renders the subject", () => {
15
+ render(<EmailPreviewCard from={from} subject="Quarterly update" />)
16
+ expect(screen.getByText("Quarterly update")).toBeTruthy()
17
+ })
18
+
19
+ it("falls back to (no subject) when subject is missing", () => {
20
+ render(<EmailPreviewCard from={from} />)
21
+ expect(screen.getByText("(no subject)")).toBeTruthy()
22
+ })
23
+
24
+ it("renders the sender name and email", () => {
25
+ render(<EmailPreviewCard from={from} />)
26
+ expect(screen.getByText("Cory Pitt")).toBeTruthy()
27
+ expect(screen.getByText("<cory@withhandled.com>")).toBeTruthy()
28
+ })
29
+
30
+ it("renders the recipient", () => {
31
+ render(<EmailPreviewCard from={from} to="jane@acme.com" />)
32
+ expect(screen.getByText("to jane@acme.com")).toBeTruthy()
33
+ expect(
34
+ screen.getByText(/This is how your email lands in jane@acme.com's inbox/),
35
+ ).toBeTruthy()
36
+ })
37
+
38
+ it("renders 'to no recipient yet' when no recipient", () => {
39
+ render(<EmailPreviewCard from={from} />)
40
+ expect(screen.getByText("to no recipient yet")).toBeTruthy()
41
+ expect(
42
+ screen.getByText(/This is how your email lands in the recipient's inbox/),
43
+ ).toBeTruthy()
44
+ })
45
+
46
+ it("renders html body markup", () => {
47
+ const { container } = render(
48
+ <EmailPreviewCard from={from} htmlBody="<strong>Hello world</strong>" />,
49
+ )
50
+ expect(container.querySelector("strong")?.textContent).toBe("Hello world")
51
+ })
52
+
53
+ it("renders the signature when provided", () => {
54
+ const { container } = render(
55
+ <EmailPreviewCard
56
+ from={from}
57
+ signatureHtml="<em>Best, Cory</em>"
58
+ />,
59
+ )
60
+ expect(container.querySelector("em")?.textContent).toBe("Best, Cory")
61
+ })
62
+ })
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest"
2
+ import React from "react"
3
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react"
4
+
5
+ import {
6
+ EmailRecipientField,
7
+ type RecipientChip,
8
+ } from "../email-recipient-field"
9
+ import type { SuggestedContact } from "../suggested-actions"
10
+
11
+ afterEach(() => {
12
+ cleanup()
13
+ })
14
+
15
+ const contacts: SuggestedContact[] = [
16
+ { name: "Alex Admin", role: "Controller", email: "alex@example.com", confirmed: true },
17
+ { name: "Bea Buyer", role: "Buyer", email: "bea@example.com", confirmed: true },
18
+ { name: "No Email", role: "Unknown", confirmed: false },
19
+ ]
20
+
21
+ function getInput(): HTMLInputElement {
22
+ return screen.getByPlaceholderText(/Add email|Add another/) as HTMLInputElement
23
+ }
24
+
25
+ describe("EmailRecipientField", () => {
26
+ it("adds an unconfirmed chip when pressing Enter on a valid email", () => {
27
+ const onChange = vi.fn()
28
+ render(
29
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
30
+ )
31
+
32
+ const input = getInput()
33
+ fireEvent.change(input, { target: { value: "new@example.com" } })
34
+ fireEvent.keyDown(input, { key: "Enter" })
35
+
36
+ expect(onChange).toHaveBeenCalledWith([
37
+ { id: "new@example.com", email: "new@example.com", name: "", confirmed: false },
38
+ ])
39
+ })
40
+
41
+ it("adds a chip when pressing comma", () => {
42
+ const onChange = vi.fn()
43
+ render(
44
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
45
+ )
46
+
47
+ const input = getInput()
48
+ fireEvent.change(input, { target: { value: "comma@example.com" } })
49
+ fireEvent.keyDown(input, { key: "," })
50
+
51
+ expect(onChange).toHaveBeenCalledWith([
52
+ { id: "comma@example.com", email: "comma@example.com", name: "", confirmed: false },
53
+ ])
54
+ })
55
+
56
+ it("does not add an invalid email", () => {
57
+ const onChange = vi.fn()
58
+ render(
59
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
60
+ )
61
+
62
+ const input = getInput()
63
+ fireEvent.change(input, { target: { value: "not-an-email" } })
64
+ fireEvent.keyDown(input, { key: "Enter" })
65
+
66
+ expect(onChange).not.toHaveBeenCalled()
67
+ })
68
+
69
+ it("confirms a chip", () => {
70
+ const onChange = vi.fn()
71
+ const recipients: RecipientChip[] = [
72
+ { id: "a@b.com", email: "a@b.com", name: "", confirmed: false },
73
+ ]
74
+ render(
75
+ <EmailRecipientField label="To" recipients={recipients} onRecipientsChange={onChange} />,
76
+ )
77
+
78
+ fireEvent.click(screen.getByRole("button", { name: "Confirm" }))
79
+ expect(onChange).toHaveBeenCalledWith([
80
+ { id: "a@b.com", email: "a@b.com", name: "", confirmed: true },
81
+ ])
82
+ })
83
+
84
+ it("removes a chip via the X button", () => {
85
+ const onChange = vi.fn()
86
+ const recipients: RecipientChip[] = [
87
+ { id: "a@b.com", email: "a@b.com", name: "Al", confirmed: true },
88
+ ]
89
+ render(
90
+ <EmailRecipientField label="To" recipients={recipients} onRecipientsChange={onChange} />,
91
+ )
92
+
93
+ fireEvent.click(screen.getByRole("button", { name: "Remove Al" }))
94
+ expect(onChange).toHaveBeenCalledWith([])
95
+ })
96
+
97
+ it("removes the last chip on Backspace with empty input", () => {
98
+ const onChange = vi.fn()
99
+ const recipients: RecipientChip[] = [
100
+ { id: "a@b.com", email: "a@b.com", name: "", confirmed: true },
101
+ { id: "c@d.com", email: "c@d.com", name: "", confirmed: true },
102
+ ]
103
+ render(
104
+ <EmailRecipientField label="To" recipients={recipients} onRecipientsChange={onChange} />,
105
+ )
106
+
107
+ const input = getInput()
108
+ fireEvent.keyDown(input, { key: "Backspace" })
109
+ expect(onChange).toHaveBeenCalledWith([
110
+ { id: "a@b.com", email: "a@b.com", name: "", confirmed: true },
111
+ ])
112
+ })
113
+
114
+ it("silently rejects emails already in addedEmails", () => {
115
+ const onChange = vi.fn()
116
+ render(
117
+ <EmailRecipientField
118
+ label="To"
119
+ recipients={[]}
120
+ onRecipientsChange={onChange}
121
+ addedEmails={new Set(["dup@example.com"])}
122
+ />,
123
+ )
124
+
125
+ const input = getInput()
126
+ fireEvent.change(input, { target: { value: "dup@example.com" } })
127
+ fireEvent.keyDown(input, { key: "Enter" })
128
+
129
+ expect(onChange).not.toHaveBeenCalled()
130
+ })
131
+
132
+ it("applies amber tint when amber and an unconfirmed chip exists", () => {
133
+ const { container } = render(
134
+ <EmailRecipientField
135
+ label="To"
136
+ amber
137
+ recipients={[{ id: "a@b.com", email: "a@b.com", name: "", confirmed: false }]}
138
+ onRecipientsChange={vi.fn()}
139
+ />,
140
+ )
141
+
142
+ const row = container.firstChild as HTMLElement
143
+ expect(row.className).toContain("bg-amber-50/35")
144
+ })
145
+
146
+ it("conditionally renders the mini-chips", () => {
147
+ const { rerender } = render(
148
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={vi.fn()} />,
149
+ )
150
+ expect(screen.queryByRole("button", { name: /Contacts/ })).toBeNull()
151
+ expect(screen.queryByRole("button", { name: /Cc\/Bcc/ })).toBeNull()
152
+
153
+ rerender(
154
+ <EmailRecipientField
155
+ label="To"
156
+ recipients={[]}
157
+ onRecipientsChange={vi.fn()}
158
+ showPicker
159
+ showCcBcc
160
+ />,
161
+ )
162
+ expect(screen.getByRole("button", { name: /Contacts/ })).toBeTruthy()
163
+ expect(screen.getByRole("button", { name: /Add Cc\/Bcc/ })).toBeTruthy()
164
+ })
165
+
166
+ it("toggles Cc/Bcc label and fires the toggle handler", () => {
167
+ const onToggle = vi.fn()
168
+ const { rerender } = render(
169
+ <EmailRecipientField
170
+ label="To"
171
+ recipients={[]}
172
+ onRecipientsChange={vi.fn()}
173
+ showCcBcc
174
+ onCcBccToggle={onToggle}
175
+ />,
176
+ )
177
+
178
+ fireEvent.click(screen.getByRole("button", { name: "Add Cc/Bcc" }))
179
+ expect(onToggle).toHaveBeenCalledTimes(1)
180
+
181
+ rerender(
182
+ <EmailRecipientField
183
+ label="To"
184
+ recipients={[]}
185
+ onRecipientsChange={vi.fn()}
186
+ showCcBcc
187
+ ccBccOpen
188
+ onCcBccToggle={onToggle}
189
+ />,
190
+ )
191
+ expect(screen.getByRole("button", { name: "Hide Cc/Bcc" })).toBeTruthy()
192
+ })
193
+
194
+ it("opens and closes the contact picker", () => {
195
+ render(
196
+ <EmailRecipientField
197
+ label="To"
198
+ recipients={[]}
199
+ onRecipientsChange={vi.fn()}
200
+ showPicker
201
+ contacts={contacts}
202
+ />,
203
+ )
204
+
205
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
206
+ expect(screen.getByPlaceholderText("Search contacts...")).toBeTruthy()
207
+ expect(screen.getByText("Alex Admin")).toBeTruthy()
208
+
209
+ fireEvent.keyDown(document, { key: "Escape" })
210
+ expect(screen.queryByPlaceholderText("Search contacts...")).toBeNull()
211
+ })
212
+
213
+ it("adds a confirmed recipient when a contact is selected", () => {
214
+ const onChange = vi.fn()
215
+ render(
216
+ <EmailRecipientField
217
+ label="To"
218
+ recipients={[]}
219
+ onRecipientsChange={onChange}
220
+ showPicker
221
+ contacts={contacts}
222
+ />,
223
+ )
224
+
225
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
226
+ fireEvent.click(screen.getByText("Alex Admin"))
227
+
228
+ expect(onChange).toHaveBeenCalledWith([
229
+ { id: "alex@example.com", email: "alex@example.com", name: "Alex Admin", confirmed: true },
230
+ ])
231
+ })
232
+
233
+ it("disables rows for added and no-email contacts", () => {
234
+ render(
235
+ <EmailRecipientField
236
+ label="To"
237
+ recipients={[]}
238
+ onRecipientsChange={vi.fn()}
239
+ showPicker
240
+ contacts={contacts}
241
+ addedEmails={new Set(["alex@example.com"])}
242
+ />,
243
+ )
244
+
245
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
246
+
247
+ const options = screen.getAllByRole("option")
248
+ const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
249
+ const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
250
+
251
+ expect(alexRow.className).toContain("pointer-events-none")
252
+ expect(alexRow.textContent).toContain("Added")
253
+ expect(noEmailRow.className).toContain("pointer-events-none")
254
+ expect(noEmailRow.textContent).toContain("No email")
255
+ })
256
+ })
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest"
2
+ import React from "react"
3
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react"
4
+
5
+ import { EmailSendBar } from "../email-send-bar"
6
+
7
+ afterEach(() => {
8
+ cleanup()
9
+ })
10
+
11
+ describe("EmailSendBar", () => {
12
+ it("renders primary and secondary actions", () => {
13
+ render(
14
+ <EmailSendBar
15
+ primaryActions={[{ key: "attach", label: "Attach" }]}
16
+ secondaryActions={[{ key: "schedule", label: "Schedule" }]}
17
+ />,
18
+ )
19
+
20
+ expect(screen.getByRole("button", { name: "Attach" })).toBeTruthy()
21
+ expect(screen.getByRole("button", { name: "Schedule" })).toBeTruthy()
22
+ })
23
+
24
+ it("renders a divider only when both groups exist", () => {
25
+ const { container, rerender } = render(
26
+ <EmailSendBar
27
+ primaryActions={[{ key: "attach", label: "Attach" }]}
28
+ secondaryActions={[{ key: "schedule", label: "Schedule" }]}
29
+ />,
30
+ )
31
+ expect(container.querySelector(".w-px.h-\\[18px\\]")).toBeTruthy()
32
+
33
+ rerender(
34
+ <EmailSendBar primaryActions={[{ key: "attach", label: "Attach" }]} />,
35
+ )
36
+ expect(container.querySelector(".w-px.h-\\[18px\\]")).toBeNull()
37
+ })
38
+
39
+ it("renders the send button with custom label", () => {
40
+ render(<EmailSendBar sendLabel="Send email" />)
41
+ expect(screen.getByRole("button", { name: "Send email" })).toBeTruthy()
42
+ })
43
+
44
+ it("disables the send button when sendDisabled is true", () => {
45
+ render(<EmailSendBar sendDisabled />)
46
+ const send = screen.getByRole("button", { name: "Send" }) as HTMLButtonElement
47
+ expect(send.disabled).toBe(true)
48
+ })
49
+
50
+ it("fires click handlers", () => {
51
+ const onSend = vi.fn()
52
+ const onAttach = vi.fn()
53
+
54
+ render(
55
+ <EmailSendBar
56
+ primaryActions={[{ key: "attach", label: "Attach", onClick: onAttach }]}
57
+ onSend={onSend}
58
+ />,
59
+ )
60
+
61
+ fireEvent.click(screen.getByRole("button", { name: "Attach" }))
62
+ fireEvent.click(screen.getByRole("button", { name: "Send" }))
63
+
64
+ expect(onAttach).toHaveBeenCalledTimes(1)
65
+ expect(onSend).toHaveBeenCalledTimes(1)
66
+ })
67
+
68
+ it("does not fire click for disabled action", () => {
69
+ const onAttach = vi.fn()
70
+ render(
71
+ <EmailSendBar
72
+ primaryActions={[
73
+ { key: "attach", label: "Attach", onClick: onAttach, disabled: true },
74
+ ]}
75
+ />,
76
+ )
77
+ const btn = screen.getByRole("button", { name: "Attach" }) as HTMLButtonElement
78
+ expect(btn.disabled).toBe(true)
79
+ })
80
+ })
@@ -16,7 +16,7 @@ const positivePills = ["Tone", "Personalization", "Length", "CTA", "Other"]
16
16
  const negativePills = ["Too formal", "Too casual", "Too long", "Missing context", "Wrong angle", "Factual error", "Other"]
17
17
 
18
18
  export interface DraftFeedbackInlineProps {
19
- initialDirection?: "up" | "down" | null
19
+ initialDirection?: 'up' | 'down' | null
20
20
  onRegenerateRequest?: (pills: string[], detail: string) => void
21
21
  onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
22
22
  onDiscardRequest?: (pills: string[], detail: string) => void
@@ -70,7 +70,7 @@ export function DraftFeedbackInline({
70
70
  if (noted) {
71
71
  return (
72
72
  <div className="flex items-center gap-1.5 py-1 animate-in fade-in slide-in-from-top-1 duration-200">
73
- <Check className="w-3.5 h-3.5 text-foreground" />
73
+ <Check className="w-3.5 h-3.5 text-emerald-500" />
74
74
  <span className="text-xs text-muted-foreground">Feedback recorded</span>
75
75
  </div>
76
76
  )
@@ -88,7 +88,7 @@ export function DraftFeedbackInline({
88
88
  }
89
89
 
90
90
  return (
91
- <div className="space-y-2">
91
+ <div className="space-y-0">
92
92
  <div className="flex items-center justify-between">
93
93
  <span className="text-sm text-foreground font-medium">How&apos;s this draft?</span>
94
94
  <div className="flex gap-1">
@@ -100,11 +100,11 @@ export function DraftFeedbackInline({
100
100
  }}
101
101
  className={`p-1.5 rounded transition-colors ${
102
102
  thumbState === "up"
103
- ? "bg-muted text-foreground"
103
+ ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400"
104
104
  : "hover:bg-muted text-muted-foreground hover:text-foreground"
105
105
  }`}
106
106
  >
107
- <ThumbsUp className="w-4 h-4" />
107
+ <ThumbsUp className="w-4 h-4" fill={thumbState === "up" ? "currentColor" : "none"} />
108
108
  </button>
109
109
  <button
110
110
  onClick={() => {
@@ -114,31 +114,31 @@ export function DraftFeedbackInline({
114
114
  }}
115
115
  className={`p-1.5 rounded transition-colors ${
116
116
  thumbState === "down"
117
- ? "bg-muted text-foreground"
117
+ ? "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400"
118
118
  : "hover:bg-muted text-muted-foreground hover:text-foreground"
119
119
  }`}
120
120
  >
121
- <ThumbsDown className="w-4 h-4" />
121
+ <ThumbsDown className="w-4 h-4" fill={thumbState === "down" ? "currentColor" : "none"} />
122
122
  </button>
123
123
  </div>
124
124
  </div>
125
125
 
126
126
  {thumbState && (
127
- <div className="pt-2 space-y-2.5 animate-in fade-in slide-in-from-top-2 duration-200">
127
+ <div className="pt-3 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
128
128
  <div>
129
129
  <span className="text-xs text-muted-foreground mb-2 block font-medium">
130
130
  {thumbState === "up" ? "What worked well?" : "What needs improvement?"}
131
131
  </span>
132
- <div className="flex flex-wrap gap-2">
132
+ <div className="flex flex-wrap gap-1.5">
133
133
  {(thumbState === "up" ? positivePills : negativePills).map((pill) => (
134
134
  <button
135
135
  key={pill}
136
136
  onClick={() => togglePill(pill)}
137
- className={`px-3 py-1.5 rounded-full text-[11px] font-medium border transition-colors ${
137
+ className={`px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors ${
138
138
  selectedPills.includes(pill)
139
139
  ? thumbState === "up"
140
- ? "bg-muted text-foreground border-border"
141
- : "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/30 dark:text-red-300 dark:border-red-800"
140
+ ? "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800"
141
+ : "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800"
142
142
  : "bg-background text-muted-foreground border-border hover:bg-muted/50 hover:text-foreground"
143
143
  }`}
144
144
  >
@@ -155,7 +155,7 @@ export function DraftFeedbackInline({
155
155
  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]"
156
156
  />
157
157
 
158
- <div className="flex items-center gap-2.5 pt-2">
158
+ <div className="flex items-center gap-2 pt-1">
159
159
  {thumbState === "down" ? (
160
160
  <>
161
161
  <button
@@ -0,0 +1,47 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+
7
+ export interface EmailComposerRowProps
8
+ extends React.HTMLAttributes<HTMLDivElement> {
9
+ label: React.ReactNode
10
+ state?: "default" | "amber"
11
+ alignStart?: boolean
12
+ children: React.ReactNode
13
+ }
14
+
15
+ export function EmailComposerRow({
16
+ label,
17
+ state = "default",
18
+ alignStart = false,
19
+ children,
20
+ className,
21
+ ...props
22
+ }: EmailComposerRowProps) {
23
+ const amber = state === "amber"
24
+
25
+ return (
26
+ <div
27
+ className={cn(
28
+ "grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-center text-sm",
29
+ alignStart && "items-start",
30
+ amber && "bg-amber-50/35 border-amber-200/80",
31
+ className,
32
+ )}
33
+ {...props}
34
+ >
35
+ <div
36
+ className={cn(
37
+ "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground",
38
+ alignStart && "pt-[7px]",
39
+ amber && "text-amber-700",
40
+ )}
41
+ >
42
+ {label}
43
+ </div>
44
+ <div className="min-w-0">{children}</div>
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,94 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Eye } from "lucide-react"
5
+
6
+ import { cn } from "../lib/utils"
7
+
8
+ export interface EmailPreviewCardProps {
9
+ from: { name: string; email: string }
10
+ to?: string
11
+ subject?: string
12
+ htmlBody?: string
13
+ textBody?: string
14
+ signatureHtml?: string | null
15
+ className?: string
16
+ }
17
+
18
+ function getInitials(name: string): string {
19
+ return name
20
+ .split(" ")
21
+ .map((part) => part[0])
22
+ .filter(Boolean)
23
+ .slice(0, 2)
24
+ .join("")
25
+ .toUpperCase()
26
+ }
27
+
28
+ function escapeHtml(text: string): string {
29
+ return text
30
+ .replace(/&/g, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;")
34
+ .replace(/'/g, "&#39;")
35
+ }
36
+
37
+ export function EmailPreviewCard({
38
+ from,
39
+ to,
40
+ subject,
41
+ htmlBody,
42
+ textBody,
43
+ signatureHtml,
44
+ className,
45
+ }: EmailPreviewCardProps) {
46
+ const recipientLabel = to ? `${to}'s` : "the recipient's"
47
+ const bodyHtml = htmlBody ?? (textBody ? escapeHtml(textBody) : "")
48
+
49
+ return (
50
+ <div className={cn("p-4 bg-muted/30 min-h-full", className)}>
51
+ <div className="flex items-start gap-2 mb-3 py-2.5 px-3 border rounded-lg bg-background text-[11.5px] text-muted-foreground">
52
+ <Eye className="size-4 shrink-0 mt-0.5" />
53
+ <span>
54
+ This is how your email lands in {recipientLabel} inbox. Nothing has
55
+ been sent yet.
56
+ </span>
57
+ </div>
58
+
59
+ <div className="bg-background border rounded-xl shadow-sm overflow-hidden">
60
+ <div className="px-[18px] pt-4 pb-3 text-base font-semibold border-b border-border/50">
61
+ {subject || "(no subject)"}
62
+ </div>
63
+
64
+ <div className="flex items-center gap-3 px-[18px] pt-3">
65
+ <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-foreground text-background text-xs font-semibold">
66
+ {getInitials(from.name)}
67
+ </div>
68
+ <div className="min-w-0 flex-1">
69
+ <div className="text-sm">
70
+ <span className="font-semibold text-foreground">{from.name}</span>{" "}
71
+ <span className="text-muted-foreground">&lt;{from.email}&gt;</span>
72
+ </div>
73
+ <div className="text-xs text-muted-foreground">
74
+ {to ? `to ${to}` : "to no recipient yet"}
75
+ </div>
76
+ </div>
77
+ <div className="text-xs text-muted-foreground shrink-0">just now</div>
78
+ </div>
79
+
80
+ <div
81
+ className="px-[18px] py-2 ml-[47px] text-[13.5px] leading-relaxed whitespace-pre-wrap"
82
+ dangerouslySetInnerHTML={{ __html: bodyHtml }}
83
+ />
84
+
85
+ {signatureHtml ? (
86
+ <div
87
+ className="ml-[47px] px-[18px] pt-3 mt-3 border-t border-border/50 pb-4 text-xs leading-relaxed text-muted-foreground"
88
+ dangerouslySetInnerHTML={{ __html: signatureHtml }}
89
+ />
90
+ ) : null}
91
+ </div>
92
+ </div>
93
+ )
94
+ }