@remit/ui 0.0.113 → 0.0.115

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 (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -0,0 +1,159 @@
1
+ import { AlertTriangle, Info, Wand2 } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import type { PhraseParse } from "../lib/event-phrase.js";
4
+
5
+ export interface EventQuickEntryProps {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ /** The reading of `value`, recomputed by the caller on every keystroke. */
9
+ parse: PhraseParse;
10
+ /** Commits the reading to the form. */
11
+ onCommit: () => void;
12
+ placeholder?: string;
13
+ touch?: boolean;
14
+ className?: string;
15
+ }
16
+
17
+ interface ReadingProps {
18
+ label: string;
19
+ value: string;
20
+ source: string;
21
+ }
22
+
23
+ function Reading({ label, value, source }: ReadingProps) {
24
+ return (
25
+ <div className="flex min-w-0 items-baseline gap-1.5">
26
+ <span className="shrink-0 text-2xs uppercase tracking-wider text-fg-subtle">
27
+ {label}
28
+ </span>
29
+ <span className="truncate text-xs font-medium text-fg">{value}</span>
30
+ {source !== "" && (
31
+ <span className="shrink-0 rounded-xs bg-accent-2-soft px-1 text-2xs text-accent-2">
32
+ {source}
33
+ </span>
34
+ )}
35
+ </div>
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Typing is the fastest way to make an event, so the field takes a sentence.
41
+ * What it does with the sentence is on screen while you type: every reading is
42
+ * shown next to the words it was taken from, and anything the reader filled in
43
+ * or could not settle is said out loud. Correcting the machine happens before
44
+ * the event exists, not after it is on the grid.
45
+ */
46
+ export function EventQuickEntry({
47
+ value,
48
+ onChange,
49
+ parse,
50
+ onCommit,
51
+ placeholder = "lunch with Jane friday 1pm",
52
+ touch,
53
+ className,
54
+ }: EventQuickEntryProps) {
55
+ const hasReading = value.trim() !== "";
56
+ const endTime =
57
+ parse.startTime === ""
58
+ ? ""
59
+ : ` – ${clockAfter(parse.startTime, parse.durationMinutes)}`;
60
+
61
+ return (
62
+ <div className={cn("flex flex-col gap-2", className)}>
63
+ <div
64
+ className={cn(
65
+ "flex items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30",
66
+ touch ? "min-h-11" : "h-9",
67
+ )}
68
+ >
69
+ <Wand2 className="size-4 shrink-0 text-fg-subtle" aria-hidden />
70
+ <input
71
+ value={value}
72
+ aria-label="Describe the event"
73
+ placeholder={placeholder}
74
+ onChange={(e) => onChange(e.target.value)}
75
+ onKeyDown={(e) => {
76
+ if (e.key === "Enter") onCommit();
77
+ }}
78
+ className="min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
79
+ />
80
+ </div>
81
+
82
+ {hasReading && (
83
+ <div className="flex flex-col gap-1 rounded-md border border-line bg-surface px-2.5 py-2">
84
+ <Reading
85
+ label="What"
86
+ value={parse.title === "" ? "—" : parse.title}
87
+ source=""
88
+ />
89
+ <Reading
90
+ label="When"
91
+ value={`${formatDay(parse.date)}${
92
+ parse.startTime === "" ? "" : ` ${parse.startTime}${endTime}`
93
+ }`}
94
+ source={[parse.dateText, parse.startTimeText]
95
+ .filter((part) => part !== "")
96
+ .join(" ")}
97
+ />
98
+ {parse.durationText !== "" && (
99
+ <Reading
100
+ label="Long"
101
+ value={formatDuration(parse.durationMinutes)}
102
+ source={parse.durationText}
103
+ />
104
+ )}
105
+ {parse.attendees.length > 0 && (
106
+ <Reading
107
+ label="Who"
108
+ value={parse.attendees.join(", ")}
109
+ source={parse.attendeesText}
110
+ />
111
+ )}
112
+ {parse.assumptions.map((note) => (
113
+ <p
114
+ key={note}
115
+ className="flex items-center gap-1.5 text-2xs text-fg-subtle"
116
+ >
117
+ <Info className="size-3 shrink-0" aria-hidden />
118
+ {note}
119
+ </p>
120
+ ))}
121
+ {parse.unresolved.map((note) => (
122
+ <p
123
+ key={note}
124
+ className="flex items-center gap-1.5 text-2xs text-warning"
125
+ >
126
+ <AlertTriangle className="size-3 shrink-0" aria-hidden />
127
+ {note}
128
+ </p>
129
+ ))}
130
+ </div>
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ function clockAfter(clock: string, minutes: number): string {
137
+ const [hours, mins] = clock.split(":").map(Number);
138
+ const total = (hours * 60 + mins + minutes) % 1440;
139
+ return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
140
+ total % 60,
141
+ ).padStart(2, "0")}`;
142
+ }
143
+
144
+ function formatDay(iso: string): string {
145
+ if (iso === "") return "—";
146
+ const [year, month, day] = iso.split("-").map(Number);
147
+ return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
148
+ weekday: "short",
149
+ day: "numeric",
150
+ month: "short",
151
+ });
152
+ }
153
+
154
+ function formatDuration(minutes: number): string {
155
+ if (minutes === 0) return "—";
156
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
157
+ if (minutes < 60) return `${minutes}m`;
158
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
159
+ }
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import type { EventSuggestion } from "./calendar-types.js";
6
+ import { EventSuggestionCard } from "./event-suggestion-card.js";
7
+
8
+ const suggestion: EventSuggestion = {
9
+ id: "s1",
10
+ title: "Stay in Lisbon",
11
+ start: "2026-06-19",
12
+ end: "2026-06-23",
13
+ allDay: true,
14
+ location: "Alfama, Lisbon",
15
+ threadId: "thr_airbnb",
16
+ threadSubject: "Your reservation in Lisbon is confirmed",
17
+ sender: "Airbnb",
18
+ confidence: 0.94,
19
+ ambiguity: "",
20
+ suggestedCalendarId: "c5",
21
+ timeZone: "Europe/Lisbon",
22
+ zoneCertainty: "explicit",
23
+ };
24
+
25
+ const render = (overrides: Partial<EventSuggestion>) =>
26
+ renderToString(
27
+ createElement(EventSuggestionCard, {
28
+ suggestion: { ...suggestion, ...overrides },
29
+ whenText: "Friday 19 June – Monday 22 June",
30
+ onAdd: () => undefined,
31
+ onReview: () => undefined,
32
+ onDismiss: () => undefined,
33
+ onOpenThread: () => undefined,
34
+ }),
35
+ );
36
+
37
+ describe("EventSuggestionCard", () => {
38
+ it("says it is a suggestion and not an event", () => {
39
+ const html = render({});
40
+ assert.match(html, /Suggested from mail/);
41
+ assert.match(html, /border-dashed/);
42
+ });
43
+
44
+ it("puts the reading into words rather than a percentage", () => {
45
+ assert.match(render({}), /Read cleanly/);
46
+ assert.match(render({ confidence: 0.7 }), /Read with gaps/);
47
+ assert.match(render({ confidence: 0.2 }), /Barely read/);
48
+ });
49
+
50
+ it("names what it could not settle", () => {
51
+ assert.match(
52
+ render({ ambiguity: "Two Tuesdays fit." }),
53
+ /Two Tuesdays fit\./,
54
+ );
55
+ });
56
+
57
+ it("credits the mail it was read out of", () => {
58
+ assert.match(render({}), /Airbnb/);
59
+ assert.match(render({}), /Your reservation in Lisbon is confirmed/);
60
+ });
61
+
62
+ it("needs a person to press Add", () => {
63
+ assert.match(render({}), />Add</);
64
+ });
65
+ });
@@ -0,0 +1,93 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import type { EventSuggestion } from "./calendar-types.js";
3
+ import { EventSuggestionCard } from "./event-suggestion-card.js";
4
+
5
+ /**
6
+ * What the reader found in a mail, waiting for a person. It sits on a dashed
7
+ * card off the grid: a suggestion is never a provisional event that someone has
8
+ * to notice and take back off the calendar.
9
+ */
10
+ const meta: Meta<typeof EventSuggestionCard> = {
11
+ title: "Calendar/Suggestion card",
12
+ component: EventSuggestionCard,
13
+ parameters: { layout: "padded" },
14
+ decorators: [
15
+ (Story) => (
16
+ <div className="max-w-sm">
17
+ <Story />
18
+ </div>
19
+ ),
20
+ ],
21
+ };
22
+ export default meta;
23
+
24
+ type Story = StoryObj<typeof EventSuggestionCard>;
25
+
26
+ const base: EventSuggestion = {
27
+ id: "s1",
28
+ title: "Stay in Lisbon",
29
+ start: "2026-06-19",
30
+ end: "2026-06-23",
31
+ allDay: true,
32
+ location: "Alfama, Lisbon",
33
+ threadId: "thr_airbnb",
34
+ threadSubject: "Your reservation in Lisbon is confirmed",
35
+ sender: "Airbnb",
36
+ confidence: 0.94,
37
+ ambiguity: "",
38
+ suggestedCalendarId: "c5",
39
+ timeZone: "Europe/Lisbon",
40
+ zoneCertainty: "explicit",
41
+ };
42
+
43
+ const handlers = {
44
+ onAdd: () => {},
45
+ onReview: () => {},
46
+ onDismiss: () => {},
47
+ onOpenThread: () => {},
48
+ };
49
+
50
+ /** A booking mail that gave every field it needed to. */
51
+ export const ReadCleanly: Story = {
52
+ render: () => (
53
+ <EventSuggestionCard
54
+ suggestion={base}
55
+ whenText="Friday 19 June – Monday 22 June"
56
+ {...handlers}
57
+ />
58
+ ),
59
+ };
60
+
61
+ /** What it could not settle is named, not smoothed over. */
62
+ export const WithAmbiguity: Story = {
63
+ render: () => (
64
+ <EventSuggestionCard
65
+ suggestion={{
66
+ ...base,
67
+ id: "s2",
68
+ title: "Analytics pilot — first call",
69
+ allDay: false,
70
+ location: "",
71
+ threadSubject: "Following up: analytics pilot proposal",
72
+ sender: "Erik Wahlberg",
73
+ confidence: 0.38,
74
+ ambiguity:
75
+ 'Asked for "some time Tuesday" and named no hour. Two Tuesdays fit.',
76
+ }}
77
+ whenText="Tuesday 16 June · 09:00 – 10:00"
78
+ {...handlers}
79
+ />
80
+ ),
81
+ };
82
+
83
+ /** The same card sized for a phone sheet. */
84
+ export const Touch: Story = {
85
+ render: () => (
86
+ <EventSuggestionCard
87
+ suggestion={base}
88
+ whenText="Friday 19 June – Monday 22 June"
89
+ touch
90
+ {...handlers}
91
+ />
92
+ ),
93
+ };
@@ -0,0 +1,116 @@
1
+ import { AlertTriangle, Mail, Plus, SlidersHorizontal, X } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { Button } from "./button.js";
4
+ import type { EventSuggestion } from "./calendar-types.js";
5
+
6
+ export interface EventSuggestionCardProps {
7
+ suggestion: EventSuggestion;
8
+ /** Already formatted by the caller. */
9
+ whenText: string;
10
+ onAdd: () => void;
11
+ onReview: () => void;
12
+ onDismiss: () => void;
13
+ onOpenThread: () => void;
14
+ touch?: boolean;
15
+ className?: string;
16
+ }
17
+
18
+ /** Words for a number, so the card never shows a false 87%. */
19
+ function confidenceText(confidence: number): string {
20
+ if (confidence >= 0.85) return "Read cleanly";
21
+ if (confidence >= 0.6) return "Read with gaps";
22
+ return "Barely read";
23
+ }
24
+
25
+ /**
26
+ * A candidate, and unmistakably not an event. It lives off the grid, on a
27
+ * dashed card, and only a person pressing Add puts anything on the calendar —
28
+ * a machine reading of a mail never becomes a provisional block that someone
29
+ * has to notice and remove.
30
+ */
31
+ export function EventSuggestionCard({
32
+ suggestion,
33
+ whenText,
34
+ onAdd,
35
+ onReview,
36
+ onDismiss,
37
+ onOpenThread,
38
+ touch,
39
+ className,
40
+ }: EventSuggestionCardProps) {
41
+ return (
42
+ <div
43
+ className={cn(
44
+ "flex flex-col gap-2 rounded-lg border border-dashed border-line-strong bg-surface-sunken p-3",
45
+ className,
46
+ )}
47
+ >
48
+ <div className="flex items-start gap-2">
49
+ <span className="min-w-0 flex-1">
50
+ <span className="block text-2xs uppercase tracking-wider text-fg-subtle">
51
+ Suggested from mail · {confidenceText(suggestion.confidence)}
52
+ </span>
53
+ <span className="block truncate text-sm font-medium text-fg">
54
+ {suggestion.title}
55
+ </span>
56
+ <span className="block text-xs text-fg-muted">{whenText}</span>
57
+ {suggestion.location !== "" && (
58
+ <span className="block truncate text-xs text-fg-muted">
59
+ {suggestion.location}
60
+ </span>
61
+ )}
62
+ </span>
63
+ <button
64
+ type="button"
65
+ aria-label="Dismiss suggestion"
66
+ onClick={onDismiss}
67
+ className={cn(
68
+ "flex shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none hover:bg-surface hover:text-fg focus-visible:ring-2 focus-visible:ring-ring",
69
+ touch ? "size-11" : "size-7",
70
+ )}
71
+ >
72
+ <X className="size-4" />
73
+ </button>
74
+ </div>
75
+
76
+ {suggestion.ambiguity !== "" && (
77
+ <p className="flex items-start gap-1.5 text-2xs text-warning">
78
+ <AlertTriangle className="mt-0.5 size-3 shrink-0" aria-hidden />
79
+ {suggestion.ambiguity}
80
+ </p>
81
+ )}
82
+
83
+ <button
84
+ type="button"
85
+ onClick={onOpenThread}
86
+ className="flex w-full min-w-0 items-center gap-1.5 rounded-sm text-left text-2xs text-accent-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
87
+ >
88
+ <Mail className="size-3 shrink-0" />
89
+ <span className="truncate">
90
+ {suggestion.sender} — {suggestion.threadSubject}
91
+ </span>
92
+ </button>
93
+
94
+ <div className="flex items-center gap-2">
95
+ <Button
96
+ variant="primary"
97
+ size={touch ? "md" : "sm"}
98
+ icon={<Plus className="size-3.5" />}
99
+ onClick={onAdd}
100
+ className={touch ? "min-h-11 flex-1" : ""}
101
+ >
102
+ Add
103
+ </Button>
104
+ <Button
105
+ variant="secondary"
106
+ size={touch ? "md" : "sm"}
107
+ icon={<SlidersHorizontal className="size-3.5" />}
108
+ onClick={onReview}
109
+ className={touch ? "min-h-11" : ""}
110
+ >
111
+ Change first
112
+ </Button>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The full-screen flow chrome: a fixed header carrying back and cancel, one
3
+ * scrolling body, and a fixed footer in the thumb zone. Every multi-decision
4
+ * surface on a phone is this shape, so there is one implementation of it.
5
+ */
6
+
7
+ import { ArrowLeft, X } from "lucide-react";
8
+ import type { ReactNode } from "react";
9
+ import { cn } from "../lib/cn.js";
10
+
11
+ export interface FlowStepRailProps {
12
+ /** How many steps the flow walks. */
13
+ count: number;
14
+ /** Zero-based index of the step on screen. */
15
+ activeStep: number;
16
+ }
17
+
18
+ export function FlowStepRail({ count, activeStep }: FlowStepRailProps) {
19
+ return (
20
+ <ol className="flex items-center gap-1.5" aria-label="Progress">
21
+ {Array.from({ length: count }, (_, i) => (
22
+ <li
23
+ // biome-ignore lint/suspicious/noArrayIndexKey: a rail segment is its position
24
+ key={i}
25
+ className="flex flex-1 items-center gap-1.5"
26
+ >
27
+ <span
28
+ className={cn(
29
+ "h-1 flex-1 rounded-full transition-colors",
30
+ i <= activeStep ? "bg-accent" : "bg-surface-sunken",
31
+ )}
32
+ aria-current={i === activeStep ? "step" : undefined}
33
+ />
34
+ </li>
35
+ ))}
36
+ </ol>
37
+ );
38
+ }
39
+
40
+ export interface FlowScreenProps {
41
+ title: string;
42
+ subtitle?: string;
43
+ /** The step labels in order. One label means one page and no rail. */
44
+ steps: readonly string[];
45
+ activeStep: number;
46
+ onBack: () => void;
47
+ onExit: () => void;
48
+ /** Omitted where the screen is read rather than answered. */
49
+ footer?: ReactNode;
50
+ children: ReactNode;
51
+ /**
52
+ * `scroll` pads the body and scrolls it. `fill` hands the whole region to a
53
+ * child that carries its own header and scrolling.
54
+ */
55
+ bodyFit?: "scroll" | "fill";
56
+ /**
57
+ * `viewport` covers the window and becomes a centred modal from 768px up.
58
+ * `container` fills the nearest positioned ancestor and stays full-bleed,
59
+ * for a surface that is already sized to a phone.
60
+ */
61
+ anchor?: "viewport" | "container";
62
+ }
63
+
64
+ /**
65
+ * The body is the only scrolling region, so back and the footer's controls
66
+ * never leave the screen. The screen covers what opened it, which is why it
67
+ * takes the accessibility tree with it.
68
+ */
69
+ export function FlowScreen({
70
+ title,
71
+ subtitle,
72
+ steps,
73
+ activeStep,
74
+ onBack,
75
+ onExit,
76
+ footer,
77
+ children,
78
+ anchor = "viewport",
79
+ bodyFit = "scroll",
80
+ }: FlowScreenProps) {
81
+ const contained = anchor === "container";
82
+ return (
83
+ <div
84
+ role="dialog"
85
+ aria-modal="true"
86
+ aria-label={title}
87
+ className={cn(
88
+ "z-50 flex flex-col font-sans text-fg",
89
+ contained
90
+ ? "absolute inset-0"
91
+ : "fixed inset-0 md:items-center md:justify-center md:bg-black/40 md:p-6",
92
+ )}
93
+ >
94
+ <div
95
+ className={cn(
96
+ "flex min-h-0 w-full flex-1 flex-col bg-canvas",
97
+ !contained &&
98
+ "md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg",
99
+ )}
100
+ >
101
+ <header
102
+ className={cn(
103
+ "shrink-0 border-b border-line px-3 pb-2",
104
+ contained
105
+ ? "pt-3"
106
+ : "pt-[calc(0.75rem+env(safe-area-inset-top,0px))] md:pt-3",
107
+ )}
108
+ >
109
+ <div className="flex items-center gap-1">
110
+ <button
111
+ type="button"
112
+ onClick={onBack}
113
+ aria-label="Back"
114
+ className="flex size-11 items-center justify-center rounded-md text-fg-muted"
115
+ >
116
+ <ArrowLeft className="size-5" />
117
+ </button>
118
+ <div className="min-w-0 flex-1 text-center">
119
+ <h1 className="truncate text-sm font-semibold">{title}</h1>
120
+ {subtitle && (
121
+ <p className="truncate text-2xs text-fg-muted">{subtitle}</p>
122
+ )}
123
+ </div>
124
+ <button
125
+ type="button"
126
+ onClick={onExit}
127
+ aria-label="Cancel"
128
+ className="flex size-11 items-center justify-center rounded-md text-fg-muted"
129
+ >
130
+ <X className="size-5" />
131
+ </button>
132
+ </div>
133
+ {steps.length > 1 && (
134
+ <div className="space-y-1 px-1 pt-2">
135
+ <FlowStepRail count={steps.length} activeStep={activeStep} />
136
+ <p className="text-2xs text-fg-subtle">
137
+ Step {activeStep + 1} of {steps.length} · {steps[activeStep]}
138
+ </p>
139
+ </div>
140
+ )}
141
+ </header>
142
+
143
+ <div
144
+ className={cn(
145
+ "min-h-0 flex-1",
146
+ bodyFit === "fill"
147
+ ? "overflow-hidden"
148
+ : "overflow-y-auto px-4 py-4",
149
+ )}
150
+ >
151
+ {children}
152
+ </div>
153
+
154
+ {footer && (
155
+ <footer
156
+ className={cn(
157
+ "shrink-0 border-t border-line px-4 py-3",
158
+ contained
159
+ ? ""
160
+ : "pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3",
161
+ )}
162
+ >
163
+ {footer}
164
+ </footer>
165
+ )}
166
+ </div>
167
+ </div>
168
+ );
169
+ }
@@ -152,13 +152,14 @@ export const WithSimilarMessages: Story = {
152
152
  args: {
153
153
  similarLinkComponent: ({
154
154
  mailboxId,
155
+ threadId,
155
156
  messageId,
156
157
  className,
157
158
  ariaLabel,
158
159
  children,
159
160
  }) => (
160
161
  <a
161
- href={`/mail/${mailboxId}?selectedMessageId=${messageId}`}
162
+ href={`/mail/${mailboxId}/${threadId}/${messageId}`}
162
163
  className={className}
163
164
  aria-label={ariaLabel}
164
165
  >
@@ -171,6 +172,7 @@ export const WithSimilarMessages: Story = {
171
172
  {
172
173
  id: "msg-1",
173
174
  mailboxId: "mbx-1",
175
+ threadId: "thr-msg-1",
174
176
  fromName: "Alex Rivera",
175
177
  subject: "Re: Q3 planning notes",
176
178
  timeLabel: "Jan 17",
@@ -179,6 +181,7 @@ export const WithSimilarMessages: Story = {
179
181
  {
180
182
  id: "msg-2",
181
183
  mailboxId: "mbx-1",
184
+ threadId: "thr-msg-2",
182
185
  fromName: "Billing",
183
186
  subject: "Your invoice is ready",
184
187
  timeLabel: "Yesterday",
@@ -187,6 +190,7 @@ export const WithSimilarMessages: Story = {
187
190
  {
188
191
  id: "msg-3",
189
192
  mailboxId: "mbx-2",
193
+ threadId: "thr-msg-3",
190
194
  fromName: "",
191
195
  subject: "(No subject)",
192
196
  timeLabel: "Dec 4, 2024",
@@ -85,6 +85,8 @@ export interface SimilarMessageIntel {
85
85
  id: string;
86
86
  /** Mailbox the message lives in — the route param for opening it. */
87
87
  mailboxId: string;
88
+ /** Conversation the message belongs to, which is what the reading pane opens. */
89
+ threadId: string;
88
90
  fromName: string;
89
91
  subject: string;
90
92
  timeLabel: string;
@@ -99,6 +101,7 @@ export interface SimilarMessageIntel {
99
101
  */
100
102
  export interface SimilarMessageLinkProps {
101
103
  mailboxId: string;
104
+ threadId: string;
102
105
  messageId: string;
103
106
  className: string;
104
107
  ariaLabel?: string;
@@ -540,6 +543,7 @@ export function IntelligencePanel({
540
543
  {similarLinkComponent ? (
541
544
  similarLinkComponent({
542
545
  mailboxId: s.mailboxId,
546
+ threadId: s.threadId,
543
547
  messageId: s.id,
544
548
  className: rowClass,
545
549
  ariaLabel,
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useMatchMedia } from "../lib/use-match-media.js";
2
3
  import { buildEmailSrcDoc, type EmailFrameVariant } from "./email-frame-css.js";
3
4
 
4
5
  export interface IsolatedEmailFrameProps {
@@ -86,24 +87,6 @@ export const computeFitScale = (
86
87
  return Math.max(MIN_SCALE, containerWidth / contentWidth);
87
88
  };
88
89
 
89
- const useMatchMedia = (query: string): boolean => {
90
- const [matches, setMatches] = useState(() => {
91
- if (typeof window === "undefined" || !window.matchMedia) return false;
92
- return window.matchMedia(query).matches;
93
- });
94
-
95
- useEffect(() => {
96
- if (typeof window === "undefined" || !window.matchMedia) return;
97
- const mql = window.matchMedia(query);
98
- setMatches(mql.matches);
99
- const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
100
- mql.addEventListener("change", handler);
101
- return () => mql.removeEventListener("change", handler);
102
- }, [query]);
103
-
104
- return matches;
105
- };
106
-
107
90
  /** Named (non-character) keys worth replaying: moving around and closing. */
108
91
  const FORWARDED_NAMED_KEYS = new Set([
109
92
  "Enter",