@remit/ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. package/src/tokens.css +217 -0
@@ -0,0 +1,546 @@
1
+ import {
2
+ Ban,
3
+ BellOff,
4
+ MailCheck,
5
+ MailX,
6
+ ShieldAlert,
7
+ ShieldCheck,
8
+ ShieldQuestion,
9
+ ShieldX,
10
+ Sparkles,
11
+ Star,
12
+ X,
13
+ } from "lucide-react";
14
+ import type { ReactElement, ReactNode } from "react";
15
+ import { cn } from "../lib/cn.js";
16
+ import { Avatar } from "./avatar.js";
17
+ import { Badge } from "./badge.js";
18
+ import { Button } from "./button.js";
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* The fourth pane: mail intelligence. Driven by real backend signals */
22
+ /* (Address engagement counters, DKIM-mismatch category heuristics, */
23
+ /* semantic search) with reserved slots for local-LLM and Bedrock */
24
+ /* features. Silent when there is nothing to say; loudest element on */
25
+ /* the screen when authenticity fails. */
26
+ /* ------------------------------------------------------------------ */
27
+
28
+ export type SenderTrustLevel = "unknown" | "wellknown" | "vip";
29
+
30
+ export interface SenderIntel {
31
+ name: string;
32
+ email: string;
33
+ trust: SenderTrustLevel;
34
+ /** Human label, e.g. "Jan 2025" or "today". */
35
+ firstSeenLabel: string;
36
+ /**
37
+ * Engagement counters. Optional: when the API does not expose them, omit
38
+ * both and the engagement clause is suppressed rather than rendering a
39
+ * misleading "0 received · you've never replied" next to an earned trust
40
+ * badge. Render the clause only when `inboundCount` is provided.
41
+ */
42
+ inboundCount?: number;
43
+ replyCount?: number;
44
+ /**
45
+ * True when the sender address is missing or unparseable (no valid domain).
46
+ * Drives a red "couldn't verify the address" badge instead of the quiet grey
47
+ * "Unknown sender", and forces the authenticity verdict to the red tier.
48
+ */
49
+ addressUnverified?: boolean;
50
+ }
51
+
52
+ export interface AuthenticityIntel {
53
+ /**
54
+ * Risk tier. `aligned` (green) — verified sender. `caution` (orange) — no
55
+ * verification signal available. `mismatch` (red) — impersonation or an
56
+ * unreadable sender address.
57
+ */
58
+ verdict: "aligned" | "caution" | "mismatch";
59
+ fromDomain: string;
60
+ /** Signing domain the message was actually sent from, when known. */
61
+ dkimDomain?: string;
62
+ /** Brand the display name claims, when it differs from the mailbox. */
63
+ claimedBrand?: string;
64
+ /** Plain-language verdict shown to the user. */
65
+ summary: string;
66
+ /**
67
+ * True when the red tier is driven by an unreadable sender address rather
68
+ * than an impersonation mismatch — selects the headline and suppresses the
69
+ * domain-comparison line (there is no valid domain to show).
70
+ */
71
+ addressUnreadable?: boolean;
72
+ /** Count of semantically similar messages (the campaign, not the instance). */
73
+ similarCount?: number;
74
+ }
75
+
76
+ export type MatchedChunk =
77
+ | "sender"
78
+ | "recipient"
79
+ | "subject"
80
+ | "attachment"
81
+ | "body"
82
+ | "entities";
83
+
84
+ export interface SimilarMessageIntel {
85
+ id: string;
86
+ /** Mailbox the message lives in — the route param for opening it. */
87
+ mailboxId: string;
88
+ fromName: string;
89
+ subject: string;
90
+ timeLabel: string;
91
+ matched: MatchedChunk;
92
+ }
93
+
94
+ /**
95
+ * Renders a similar-message row as a real anchor so middle-click /
96
+ * open-in-new-tab / deep-linking / screen-reader link semantics all work. The
97
+ * web-client passes a router `<Link>` builder; when omitted the row falls back
98
+ * to a non-navigating element that preserves the visual (static stories).
99
+ */
100
+ export interface SimilarMessageLinkProps {
101
+ mailboxId: string;
102
+ messageId: string;
103
+ className: string;
104
+ ariaLabel?: string;
105
+ children: ReactNode;
106
+ }
107
+
108
+ export type SimilarMessageLinkComponent = (
109
+ props: SimilarMessageLinkProps,
110
+ ) => ReactElement;
111
+
112
+ export interface SenderFlagsIntel {
113
+ vip?: boolean;
114
+ muted?: boolean;
115
+ blocked?: boolean;
116
+ unsubscribed?: boolean;
117
+ }
118
+
119
+ export interface IntelligenceData {
120
+ sender: SenderIntel;
121
+ authenticity: AuthenticityIntel;
122
+ category: { value: string; overridden?: boolean };
123
+ flags?: SenderFlagsIntel;
124
+ similar: SimilarMessageIntel[];
125
+ }
126
+
127
+ export interface IntelligenceQuickActions {
128
+ onToggleVip?: () => void;
129
+ onToggleMute?: () => void;
130
+ /** Block navigates through a confirm dialog — the callback fires post-confirm. */
131
+ onToggleBlock?: () => void;
132
+ onToggleUnsubscribe?: () => void;
133
+ onReclassify?: () => void;
134
+ /**
135
+ * "Not spam": move the message out of Junk and promote the sender to
136
+ * Wellknown (issue #594). Wired only when the message is currently in Junk.
137
+ */
138
+ onNotSpam?: () => void;
139
+ /**
140
+ * "Mark spam": move the message into Junk and strip the sender's trust
141
+ * (the inverse of "Not spam"). Wired only when the message is not in Junk.
142
+ */
143
+ onMarkSpam?: () => void;
144
+ }
145
+
146
+ /**
147
+ * Async state for the similar-messages section. The host computes these from
148
+ * the semantic-search query so the panel can show a skeleton while in flight
149
+ * and "similarity search unavailable" on failure (the rest of the sidebar
150
+ * still renders). Defaults to "ready" when omitted.
151
+ */
152
+ export type SimilarState = "loading" | "error" | "ready";
153
+
154
+ export interface IntelligencePanelProps {
155
+ data: IntelligenceData;
156
+ onClose?: () => void;
157
+ onShowSimilar?: () => void;
158
+ /** Quick-action callbacks. When omitted the buttons are visual-only. */
159
+ actions?: IntelligenceQuickActions;
160
+ /** Loading/error state for the similar-messages section. */
161
+ similarState?: SimilarState;
162
+ /**
163
+ * Render-prop that wraps each similar-message row in a real router anchor so
164
+ * it opens that email (deep-link / middle-click / link a11y). When omitted the
165
+ * rows render as static, non-navigating elements (stories / SSR previews).
166
+ */
167
+ similarLinkComponent?: SimilarMessageLinkComponent;
168
+ /** Layout overrides (e.g. when hosted inside a resizable panel). */
169
+ className?: string;
170
+ /**
171
+ * Suppress the panel's own close (X) button. Set when the panel is hosted
172
+ * inside a chrome that already provides a close affordance (e.g. the mobile
173
+ * Drawer header), so there is exactly one way back (#874).
174
+ */
175
+ hideCloseButton?: boolean;
176
+ }
177
+
178
+ const trustLabel: Record<
179
+ SenderTrustLevel,
180
+ { label: string; tone: "neutral" | "positive" | "accent" }
181
+ > = {
182
+ unknown: { label: "Unknown sender", tone: "neutral" },
183
+ wellknown: { label: "Known sender", tone: "positive" },
184
+ vip: { label: "VIP", tone: "accent" },
185
+ };
186
+
187
+ function Section({
188
+ label,
189
+ children,
190
+ className,
191
+ }: {
192
+ label: string;
193
+ children: ReactNode;
194
+ className?: string;
195
+ }) {
196
+ return (
197
+ <section
198
+ className={cn("border-b border-line px-row-inset py-3", className)}
199
+ >
200
+ <h3 className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
201
+ {label}
202
+ </h3>
203
+ <div className="mt-2">{children}</div>
204
+ </section>
205
+ );
206
+ }
207
+
208
+ function SenderCard({ sender }: { sender: SenderIntel }) {
209
+ const trust = trustLabel[sender.trust];
210
+ // Engagement clause renders only when counters are present. When the API
211
+ // does not expose inboundCount/replyCount we show first-seen alone rather
212
+ // than a misleading "0 received · you've never replied".
213
+ const engagement =
214
+ sender.inboundCount == null
215
+ ? null
216
+ : (sender.replyCount ?? 0) > 0
217
+ ? `${sender.inboundCount} received · you replied ${sender.replyCount}×`
218
+ : `${sender.inboundCount} received · you've never replied`;
219
+ return (
220
+ <div>
221
+ <div className="flex items-center gap-3">
222
+ <Avatar name={sender.name} email={sender.email} size="md" />
223
+ <div className="min-w-0">
224
+ <div className="truncate text-sm font-semibold text-fg">
225
+ {sender.name}
226
+ </div>
227
+ <div className="truncate text-2xs text-fg-subtle">{sender.email}</div>
228
+ </div>
229
+ </div>
230
+ <div className="mt-2 flex items-center gap-2">
231
+ {sender.addressUnverified ? (
232
+ <Badge tone="danger" dot>
233
+ Address couldn't be verified
234
+ </Badge>
235
+ ) : (
236
+ <Badge tone={trust.tone} dot>
237
+ {trust.label}
238
+ </Badge>
239
+ )}
240
+ </div>
241
+ <p className="mt-2 text-xs text-fg-muted">
242
+ First seen {sender.firstSeenLabel}
243
+ {engagement ? ` · ${engagement}` : ""}
244
+ </p>
245
+ </div>
246
+ );
247
+ }
248
+
249
+ function Authenticity({
250
+ auth,
251
+ onShowSimilar,
252
+ }: {
253
+ auth: AuthenticityIntel;
254
+ onShowSimilar?: () => void;
255
+ }) {
256
+ if (auth.verdict === "aligned") {
257
+ return (
258
+ <div className="flex items-start gap-2 text-xs text-fg-muted">
259
+ <ShieldCheck className="mt-0.5 size-3.5 shrink-0 text-positive" />
260
+ <span>{auth.summary}</span>
261
+ </div>
262
+ );
263
+ }
264
+ if (auth.verdict === "caution") {
265
+ return (
266
+ <div className="flex items-start gap-2 rounded-md bg-warning/10 p-3 text-warning">
267
+ <ShieldQuestion className="mt-0.5 size-4 shrink-0" />
268
+ <p className="text-xs leading-relaxed">{auth.summary}</p>
269
+ </div>
270
+ );
271
+ }
272
+ const headline = auth.addressUnreadable
273
+ ? "This sender can't be verified"
274
+ : "This message may be impersonating someone";
275
+ return (
276
+ <div className="rounded-md bg-danger-soft p-3">
277
+ <div className="flex items-start gap-2">
278
+ <ShieldAlert className="mt-0.5 size-4 shrink-0 text-danger" />
279
+ <div className="min-w-0">
280
+ <p className="text-sm font-semibold text-danger">{headline}</p>
281
+ <p className="mt-1 text-xs leading-relaxed text-fg">{auth.summary}</p>
282
+ {!auth.addressUnreadable && auth.dkimDomain && (
283
+ <p className="mt-1 text-2xs text-fg-muted">
284
+ Sent from{" "}
285
+ <code className="rounded bg-surface px-1 py-0.5">
286
+ {auth.dkimDomain}
287
+ </code>
288
+ , claims to be{" "}
289
+ <code className="rounded bg-surface px-1 py-0.5">
290
+ {auth.fromDomain}
291
+ </code>
292
+ </p>
293
+ )}
294
+ </div>
295
+ </div>
296
+ {auth.similarCount != null && auth.similarCount > 0 && (
297
+ <Button
298
+ variant="secondary"
299
+ size="sm"
300
+ className="mt-2 w-full border-danger/30 text-danger hover:bg-danger-soft"
301
+ onClick={onShowSimilar}
302
+ >
303
+ {auth.similarCount} similar messages
304
+ </Button>
305
+ )}
306
+ </div>
307
+ );
308
+ }
309
+
310
+ function QuickAction({
311
+ icon,
312
+ label,
313
+ active,
314
+ danger,
315
+ onClick,
316
+ }: {
317
+ icon: ReactNode;
318
+ label: string;
319
+ active?: boolean;
320
+ danger?: boolean;
321
+ onClick?: () => void;
322
+ }) {
323
+ return (
324
+ <button
325
+ type="button"
326
+ onClick={onClick}
327
+ className={cn(
328
+ "flex items-center gap-2 rounded-md border px-2 py-1 text-xs transition-colors",
329
+ active
330
+ ? "border-accent-2 bg-accent-2-soft text-accent-2"
331
+ : "border-line text-fg-muted hover:border-line-strong hover:text-fg",
332
+ danger && !active && "text-danger hover:bg-danger-soft",
333
+ )}
334
+ >
335
+ {icon}
336
+ {label}
337
+ </button>
338
+ );
339
+ }
340
+
341
+ const matchTone: Record<MatchedChunk, string> = {
342
+ sender: "matched: sender",
343
+ recipient: "matched: recipient",
344
+ subject: "matched: subject",
345
+ attachment: "matched: attachment",
346
+ body: "matched: body",
347
+ entities: "matched: entities",
348
+ };
349
+
350
+ export function IntelligencePanel({
351
+ data,
352
+ onClose,
353
+ onShowSimilar,
354
+ actions,
355
+ similarState = "ready",
356
+ similarLinkComponent,
357
+ className,
358
+ hideCloseButton = false,
359
+ }: IntelligencePanelProps) {
360
+ const { sender, authenticity, category, flags = {}, similar } = data;
361
+ const suspicious = authenticity.verdict === "mismatch";
362
+
363
+ return (
364
+ <aside
365
+ className={cn(
366
+ "flex w-76 shrink-0 flex-col overflow-y-auto border-l border-line bg-surface",
367
+ className,
368
+ )}
369
+ >
370
+ {/* pane-header datum: aligns with list + reading pane headers */}
371
+ <header className="flex h-pane-header shrink-0 items-center justify-between border-b border-line px-row-inset">
372
+ <span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
373
+ Intelligence
374
+ </span>
375
+ {!hideCloseButton && (
376
+ <Button
377
+ variant="ghost"
378
+ size="sm"
379
+ icon={<X className="size-3.5" />}
380
+ onClick={onClose}
381
+ aria-label="Collapse intelligence sidebar"
382
+ />
383
+ )}
384
+ </header>
385
+
386
+ <Section label="Sender">
387
+ <SenderCard sender={sender} />
388
+ </Section>
389
+
390
+ <Section label="Authenticity">
391
+ <Authenticity auth={authenticity} onShowSimilar={onShowSimilar} />
392
+ </Section>
393
+
394
+ <Section label="Category">
395
+ <div className="flex items-center gap-2">
396
+ <Badge tone={suspicious ? "danger" : "neutral"}>
397
+ {category.value}
398
+ </Badge>
399
+ {category.overridden && (
400
+ <span className="text-2xs text-fg-subtle">your override</span>
401
+ )}
402
+ <button
403
+ type="button"
404
+ className="ml-auto text-2xs text-accent hover:underline"
405
+ onClick={actions?.onReclassify}
406
+ >
407
+ reclassify
408
+ </button>
409
+ </div>
410
+ </Section>
411
+
412
+ <Section label="Quick actions">
413
+ <div className="flex flex-wrap gap-1.5">
414
+ <QuickAction
415
+ icon={<Star className="size-3.5" />}
416
+ label="VIP"
417
+ active={flags.vip}
418
+ onClick={actions?.onToggleVip}
419
+ />
420
+ <QuickAction
421
+ icon={<BellOff className="size-3.5" />}
422
+ label="Mute"
423
+ active={flags.muted}
424
+ onClick={actions?.onToggleMute}
425
+ />
426
+ <QuickAction
427
+ icon={<Ban className="size-3.5" />}
428
+ label="Block"
429
+ active={flags.blocked}
430
+ danger
431
+ onClick={actions?.onToggleBlock}
432
+ />
433
+ <QuickAction
434
+ icon={<MailX className="size-3.5" />}
435
+ label="Unsubscribe"
436
+ active={flags.unsubscribed}
437
+ onClick={actions?.onToggleUnsubscribe}
438
+ />
439
+ {actions?.onNotSpam && (
440
+ <QuickAction
441
+ icon={<MailCheck className="size-3.5" />}
442
+ label="Not spam"
443
+ onClick={actions.onNotSpam}
444
+ />
445
+ )}
446
+ {actions?.onMarkSpam && (
447
+ <QuickAction
448
+ icon={<ShieldX className="size-3.5" />}
449
+ label="Mark spam"
450
+ danger
451
+ onClick={actions.onMarkSpam}
452
+ />
453
+ )}
454
+ </div>
455
+ </Section>
456
+
457
+ {(similarState !== "ready" || similar.length > 0) && (
458
+ <Section label="Similar messages">
459
+ {similarState === "loading" ? (
460
+ <div className="animate-pulse space-y-2">
461
+ {Array.from({ length: 3 }).map((_, i) => (
462
+ // biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
463
+ <div key={i} className="space-y-1">
464
+ <div className="h-3 w-3/4 rounded bg-surface-sunken" />
465
+ <div className="h-2.5 w-1/2 rounded bg-surface-sunken" />
466
+ </div>
467
+ ))}
468
+ </div>
469
+ ) : similarState === "error" ? (
470
+ <p className="text-xs text-fg-subtle">
471
+ Similarity search unavailable
472
+ </p>
473
+ ) : (
474
+ <ul className="-mx-1 space-y-1">
475
+ {similar.map((s) => {
476
+ const rowClass =
477
+ "block w-full rounded-md px-1 py-1 text-left hover:bg-surface-sunken";
478
+ const ariaLabel = `Open message from ${s.fromName || "unknown sender"}: ${s.subject}`;
479
+ const inner = (
480
+ <>
481
+ <div className="flex items-baseline justify-between gap-2">
482
+ <span className="truncate text-xs font-medium text-fg">
483
+ {s.fromName}
484
+ </span>
485
+ <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
486
+ {s.timeLabel}
487
+ </span>
488
+ </div>
489
+ <div className="truncate text-xs text-fg-muted">
490
+ {s.subject}
491
+ </div>
492
+ <span className="mt-1 inline-block rounded-full bg-surface-sunken px-1.5 py-px text-2xs text-fg-subtle">
493
+ {matchTone[s.matched]}
494
+ </span>
495
+ </>
496
+ );
497
+ return (
498
+ <li key={s.id}>
499
+ {similarLinkComponent ? (
500
+ similarLinkComponent({
501
+ mailboxId: s.mailboxId,
502
+ messageId: s.id,
503
+ className: rowClass,
504
+ ariaLabel,
505
+ children: inner,
506
+ })
507
+ ) : (
508
+ <div className={rowClass}>{inner}</div>
509
+ )}
510
+ </li>
511
+ );
512
+ })}
513
+ </ul>
514
+ )}
515
+ </Section>
516
+ )}
517
+
518
+ <Section label="Coming soon" className="border-b-0 opacity-70">
519
+ <div className="space-y-2">
520
+ <div className="flex items-start gap-2 rounded-md border border-dashed border-line p-2">
521
+ <Sparkles className="mt-0.5 size-3.5 shrink-0 text-fg-subtle" />
522
+ <div>
523
+ <div className="text-xs font-medium text-fg-muted">
524
+ Suggested actions
525
+ </div>
526
+ <div className="text-2xs text-fg-subtle">
527
+ "Create a filter for messages like this" — on-device model
528
+ </div>
529
+ </div>
530
+ </div>
531
+ <div className="flex items-start gap-2 rounded-md border border-dashed border-line p-2">
532
+ <Sparkles className="mt-0.5 size-3.5 shrink-0 text-fg-subtle" />
533
+ <div>
534
+ <div className="text-xs font-medium text-fg-muted">
535
+ Thread summary
536
+ </div>
537
+ <div className="text-2xs text-fg-subtle">
538
+ One-paragraph digest — Bedrock
539
+ </div>
540
+ </div>
541
+ </div>
542
+ </div>
543
+ </Section>
544
+ </aside>
545
+ );
546
+ }
@@ -0,0 +1,147 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildEmailSrcDoc,
5
+ DARK_OPT_IN_RE,
6
+ generateFramedEmailBaseCSS,
7
+ generatePlainEmailBaseCSS,
8
+ VIEWPORT_META,
9
+ } from "./email-frame-css.js";
10
+ import { computeFitScale, measureContentAxis } from "./isolated-email-frame.js";
11
+
12
+ describe("measureContentAxis (content-sizing)", () => {
13
+ it("takes the larger of body and documentElement scroll size", () => {
14
+ assert.equal(measureContentAxis(600, 900, 10_000), 900);
15
+ assert.equal(measureContentAxis(900, 600, 10_000), 900);
16
+ });
17
+
18
+ it("rounds UP so a fractional content size never leaves a 1px phantom overflow", () => {
19
+ assert.equal(measureContentAxis(600.1, 0, 10_000), 601);
20
+ assert.equal(measureContentAxis(0, 899.4, 10_000), 900);
21
+ });
22
+
23
+ it("caps at the supplied max so a hostile sender can't allocate unbounded layout", () => {
24
+ assert.equal(measureContentAxis(50_001, 0, 50_000), 50_000);
25
+ assert.equal(measureContentAxis(0, 25_000, 10_000), 10_000);
26
+ });
27
+
28
+ it("returns an exact integer for already-integral content (no spurious +1)", () => {
29
+ assert.equal(measureContentAxis(672, 0, 10_000), 672);
30
+ assert.equal(measureContentAxis(0, 0, 10_000), 0);
31
+ });
32
+ });
33
+
34
+ describe("computeFitScale (mobile fit-to-width #727)", () => {
35
+ it("does not scale content that already fits the container", () => {
36
+ assert.equal(computeFitScale(364, 364), 1);
37
+ assert.equal(computeFitScale(300, 364), 1);
38
+ });
39
+
40
+ it("downscales a fixed-width newsletter to the container width", () => {
41
+ // 648px Node-Weekly table into a 364px phone container.
42
+ assert.equal(computeFitScale(648, 364), 364 / 648);
43
+ });
44
+
45
+ it("floors the scale so a pathologically wide email stays readable", () => {
46
+ assert.equal(computeFitScale(4000, 364), 0.4);
47
+ });
48
+
49
+ it("never upscales and never divides by an unknown width", () => {
50
+ assert.equal(computeFitScale(0, 364), 1);
51
+ assert.equal(computeFitScale(648, 0), 1);
52
+ assert.equal(computeFitScale(-10, 364), 1);
53
+ });
54
+ });
55
+
56
+ describe("generatePlainEmailBaseCSS (theme tokens pinned)", () => {
57
+ it("injects the light-theme resolved tokens", () => {
58
+ const css = generatePlainEmailBaseCSS(false);
59
+ assert.match(css, /oklch\(0\.3 0\.025 235\)/);
60
+ assert.match(css, /oklch\(0\.975 0\.012 90\)/);
61
+ assert.match(css, /oklch\(0\.55 0\.14 150\)/);
62
+ });
63
+
64
+ it("injects the dark-theme resolved tokens", () => {
65
+ const css = generatePlainEmailBaseCSS(true);
66
+ assert.match(css, /oklch\(0\.88 0\.02 90\)/);
67
+ assert.match(css, /oklch\(0\.25 0\.025 220\)/);
68
+ assert.match(css, /oklch\(0\.78 0\.16 150\)/);
69
+ });
70
+
71
+ it("strips author colors and backgrounds from body descendants only", () => {
72
+ const css = generatePlainEmailBaseCSS(false);
73
+ assert.match(css, /body \*\s*\{[^}]*color:\s*inherit\s*!important/);
74
+ assert.match(
75
+ css,
76
+ /body \*\s*\{[^}]*background-color:\s*transparent\s*!important/,
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("generateFramedEmailBaseCSS (K-9 dark strategy)", () => {
82
+ it("light theme renders as authored on a white canvas, no invert", () => {
83
+ const css = generateFramedEmailBaseCSS(false, false);
84
+ assert.match(css, /background-color:#ffffff/);
85
+ assert.match(css, /color-scheme:light/);
86
+ assert.doesNotMatch(css, /invert/);
87
+ });
88
+
89
+ it("dark theme without opt-in smart-inverts to darken into the pane", () => {
90
+ const css = generateFramedEmailBaseCSS(true, false);
91
+ assert.match(css, /filter:invert\(0\.92\) hue-rotate\(180deg\)/);
92
+ // Media re-inverted back to natural color.
93
+ assert.match(css, /img,picture,video[^{]*\{filter:invert/);
94
+ });
95
+
96
+ it("dark theme WITH opt-in preserves the author's own dark design (no invert)", () => {
97
+ const css = generateFramedEmailBaseCSS(true, true);
98
+ assert.match(css, /color-scheme:dark light/);
99
+ assert.doesNotMatch(css, /invert/);
100
+ });
101
+ });
102
+
103
+ describe("DARK_OPT_IN_RE", () => {
104
+ it("detects color-scheme: dark and prefers-color-scheme: dark", () => {
105
+ assert.ok(DARK_OPT_IN_RE.test(":root{color-scheme: dark}"));
106
+ assert.ok(
107
+ DARK_OPT_IN_RE.test("@media (prefers-color-scheme: dark){body{}}"),
108
+ );
109
+ });
110
+
111
+ it("does not match a plain light email", () => {
112
+ assert.ok(!DARK_OPT_IN_RE.test("<p>hello</p>"));
113
+ });
114
+ });
115
+
116
+ describe("buildEmailSrcDoc", () => {
117
+ it("prepends the viewport meta so author width=device-width resolves to the frame", () => {
118
+ const doc = buildEmailSrcDoc("<p>x</p>", "plain", false);
119
+ assert.ok(doc.startsWith(VIEWPORT_META));
120
+ });
121
+
122
+ it("keeps the sanitized email body intact after the injected style", () => {
123
+ const body = '<style>.clamp{}</style><table width="600"></table>';
124
+ const doc = buildEmailSrcDoc(body, "framed", false);
125
+ assert.ok(doc.endsWith(body));
126
+ });
127
+
128
+ it("uses the plain base CSS for the plain variant", () => {
129
+ const doc = buildEmailSrcDoc("<p>x</p>", "plain", false);
130
+ assert.match(doc, /Plain-email base/);
131
+ });
132
+
133
+ it("uses the framed smart-invert in dark mode for a non-opt-in framed email", () => {
134
+ const doc = buildEmailSrcDoc("<p>x</p>", "framed", true);
135
+ assert.match(doc, /filter:invert\(0\.92\)/);
136
+ });
137
+
138
+ it("preserves a framed email's own dark design without inverting", () => {
139
+ const doc = buildEmailSrcDoc(
140
+ "<style>:root{color-scheme:dark}</style><p>x</p>",
141
+ "framed",
142
+ true,
143
+ );
144
+ assert.match(doc, /color-scheme:dark light/);
145
+ assert.doesNotMatch(doc, /filter:invert/);
146
+ });
147
+ });