@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,148 @@
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 { ThreadData, ThreadMessageData } from "./app-shell-types.js";
6
+ import {
7
+ CollapsedMessage,
8
+ ExpandedMessage,
9
+ ReadingPane,
10
+ } from "./reading-pane.js";
11
+
12
+ const message: ThreadMessageData = {
13
+ id: "m-1",
14
+ fromName: "Jamie Chen",
15
+ fromEmail: "jamie@example.com",
16
+ toLabel: "Alex Rivera, you",
17
+ dateLabel: "Today, 09:11",
18
+ snippet: "Circulating the deck this afternoon.",
19
+ bodyHtml: "<p>body</p>",
20
+ };
21
+
22
+ const thread: ThreadData = {
23
+ subject: "Q3 planning notes",
24
+ messages: [
25
+ {
26
+ id: "msg-1",
27
+ fromName: "Alex Rivera",
28
+ fromEmail: "alex@example.com",
29
+ toLabel: "you",
30
+ dateLabel: "Today, 09:11",
31
+ snippet: "Here's where we landed after the call.",
32
+ bodyHtml: "<p>The roadmap stands and the budget is locked.</p>",
33
+ expanded: true,
34
+ },
35
+ ],
36
+ };
37
+
38
+ describe("ReadingPane", () => {
39
+ it("renders the subject and the expanded message header with a thread", () => {
40
+ const html = renderToString(createElement(ReadingPane, { thread }));
41
+ assert.match(html, /Q3 planning notes/);
42
+ // The email body now renders through the kit's sanitize → sandboxed
43
+ // IsolatedEmailFrame pipeline (#940), which needs a DOM and so emits no
44
+ // body text under SSR. Assert the expanded message's sender header
45
+ // instead; body rendering is covered by the sanitizer + MessageBodyView
46
+ // tests.
47
+ assert.match(html, /Alex Rivera/);
48
+ });
49
+
50
+ it("renders the empty state with no thread", () => {
51
+ const html = renderToString(createElement(ReadingPane, {}));
52
+ assert.doesNotMatch(html, /Q3 planning notes/);
53
+ assert.match(html, /Select a thread to read/);
54
+ });
55
+
56
+ it("renders the toolbar with the search input", () => {
57
+ const html = renderToString(createElement(ReadingPane, { thread }));
58
+ assert.match(html, /Search mail/);
59
+ });
60
+ });
61
+
62
+ describe("CollapsedMessage row slots (#945)", () => {
63
+ it("renders the fixture date label when no trailing slot is given", () => {
64
+ const html = renderToString(createElement(CollapsedMessage, { message }));
65
+ assert.match(html, /Today, 09:11/);
66
+ assert.match(html, /Jamie Chen/);
67
+ });
68
+
69
+ it("renders the app's trailing cluster instead of the date label", () => {
70
+ const html = renderToString(
71
+ createElement(CollapsedMessage, {
72
+ message,
73
+ trailing: createElement("span", null, "STAR-DATE-SLOT"),
74
+ }),
75
+ );
76
+ assert.match(html, /STAR-DATE-SLOT/);
77
+ assert.doesNotMatch(html, /Today, 09:11/);
78
+ });
79
+
80
+ it("shows the unread dot and bolds the sender when isUnread", () => {
81
+ const html = renderToString(
82
+ createElement(CollapsedMessage, { message, isUnread: true }),
83
+ );
84
+ assert.match(html, /font-semibold/);
85
+ });
86
+ });
87
+
88
+ describe("CollapsedMessage nested-button fix (#1232)", () => {
89
+ it("renders the clickable row as a role=button div so a trailing button doesn't nest inside a button", () => {
90
+ const html = renderToString(
91
+ createElement(CollapsedMessage, {
92
+ message,
93
+ onClick: () => {},
94
+ trailing: createElement(
95
+ "button",
96
+ { type: "button", "aria-label": "Add star" },
97
+ "star",
98
+ ),
99
+ }),
100
+ );
101
+ // The row itself is a div, keyboard-operable via role/tabindex — not a
102
+ // native <button>. The only <button> in the output is the trailing star.
103
+ assert.match(html, /^<div/);
104
+ assert.match(html, /role="button"/);
105
+ assert.match(html, /tabindex="0"/);
106
+ assert.match(html, /aria-expanded="false"/);
107
+ assert.equal(html.match(/<button/g)?.length ?? 0, 1);
108
+ });
109
+
110
+ it("renders a static row (no onClick) with no button role and no tabindex", () => {
111
+ const html = renderToString(createElement(CollapsedMessage, { message }));
112
+ assert.doesNotMatch(html, /role="button"/);
113
+ assert.doesNotMatch(html, /tabindex=/);
114
+ });
115
+ });
116
+
117
+ describe("ExpandedMessage row slots (#945)", () => {
118
+ it("injects the app body slot instead of the fixture MessageBodyView", () => {
119
+ const html = renderToString(
120
+ createElement(ExpandedMessage, {
121
+ message,
122
+ body: createElement("div", null, "REAL-MESSAGE-BODY"),
123
+ }),
124
+ );
125
+ assert.match(html, /REAL-MESSAGE-BODY/);
126
+ });
127
+
128
+ it("renders the sender badge, action menu and recipient slots", () => {
129
+ const html = renderToString(
130
+ createElement(ExpandedMessage, {
131
+ message,
132
+ senderBadge: createElement("span", null, "BADGE"),
133
+ actionMenu: createElement("span", null, "ACTION-MENU"),
134
+ to: createElement("span", null, "TO-LINE"),
135
+ }),
136
+ );
137
+ assert.match(html, /BADGE/);
138
+ assert.match(html, /ACTION-MENU/);
139
+ assert.match(html, /TO-LINE/);
140
+ });
141
+
142
+ it("hides the recipient line when `to` is null", () => {
143
+ const html = renderToString(
144
+ createElement(ExpandedMessage, { message, to: null }),
145
+ );
146
+ assert.doesNotMatch(html, /to Alex Rivera/);
147
+ });
148
+ });
@@ -0,0 +1,194 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Paperclip, Star } from "lucide-react";
3
+ import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
4
+ import {
5
+ CollapsedMessage,
6
+ ExpandedMessage,
7
+ ReadingPane,
8
+ } from "./reading-pane.js";
9
+
10
+ const thread: ThreadData = {
11
+ subject: "Q3 planning notes",
12
+ messages: [
13
+ {
14
+ id: "msg-1",
15
+ fromName: "Alex Rivera",
16
+ fromEmail: "alex@example.com",
17
+ toLabel: "you",
18
+ dateLabel: "Yesterday, 14:02",
19
+ snippet: "Here's where we landed after the call…",
20
+ bodyHtml:
21
+ "<p>Here's where we landed after the call. The roadmap stands.</p>",
22
+ },
23
+ {
24
+ id: "msg-2",
25
+ fromName: "Jamie Chen",
26
+ fromEmail: "jamie@example.com",
27
+ toLabel: "Alex Rivera, you",
28
+ dateLabel: "Today, 09:11",
29
+ snippet: "Thanks — I'll circulate the deck this afternoon.",
30
+ bodyHtml:
31
+ "<p>Thanks for the summary. I'll circulate the deck this afternoon and follow up with finance.</p><ul><li>Confirm headcount</li><li>Lock the budget</li></ul>",
32
+ expanded: true,
33
+ },
34
+ ],
35
+ };
36
+
37
+ // A designed (framed) newsletter so the Screens story exercises the REAL
38
+ // renderer — sanitized + sandboxed iframe, flush layout, #727 scale-to-fit —
39
+ // rather than a plain inline paragraph (#940). The fixed 600px table is the
40
+ // kind of markup that overflowed a phone before #727.
41
+ const newsletterThread: ThreadData = {
42
+ subject: "Node Weekly — Issue 540",
43
+ messages: [
44
+ {
45
+ id: "nl-1",
46
+ fromName: "Node Weekly",
47
+ fromEmail: "news@nodeweekly.example",
48
+ toLabel: "you",
49
+ dateLabel: "Today, 08:00",
50
+ snippet: "Node.js 24 hits LTS, and more…",
51
+ framed: true,
52
+ expanded: true,
53
+ bodyHtml: `<table width="600" cellpadding="0" cellspacing="0" style="margin:0 auto;border-collapse:collapse;">
54
+ <tr><td width="600" style="width:600px;background:#83cd29;padding:24px;font-family:Helvetica,Arial,sans-serif;color:#ffffff;">
55
+ <h1 style="margin:0;font-size:26px;">Node Weekly</h1>
56
+ <p style="margin:4px 0 0;font-size:14px;">Issue 540</p>
57
+ </td></tr>
58
+ <tr><td width="600" style="width:600px;padding:24px;font-family:Georgia,serif;color:#1a1a1a;">
59
+ <h2 style="font-size:18px;color:#111;">Node.js 24 hits LTS</h2>
60
+ <p>The permission model graduated from experimental and the test runner picked up snapshot testing.</p>
61
+ <p><a href="https://example.com/issue/540" style="color:#43853d;">Read the full issue &rarr;</a></p>
62
+ </td></tr>
63
+ </table>`,
64
+ },
65
+ ],
66
+ };
67
+
68
+ const meta: Meta<typeof ReadingPane> = {
69
+ title: "Screens/Kit/ReadingPane",
70
+ component: ReadingPane,
71
+ parameters: { layout: "fullscreen" },
72
+ render: (args) => (
73
+ <div className="h-screen border border-line">
74
+ <ReadingPane {...args} />
75
+ </div>
76
+ ),
77
+ };
78
+ export default meta;
79
+
80
+ type Story = StoryObj<typeof ReadingPane>;
81
+
82
+ export const WithThread: Story = { args: { thread } };
83
+
84
+ /** A designed newsletter rendered through the real sanitize → sandboxed-iframe
85
+ * pipeline — the same rendering the live app shows (#940). */
86
+ export const Newsletter: Story = { args: { thread: newsletterThread } };
87
+
88
+ export const Empty: Story = { args: { thread: undefined } };
89
+
90
+ export const WithIntelligenceToggle: Story = {
91
+ args: { thread, showIntelligenceToggle: true, intelligenceOpen: false },
92
+ };
93
+
94
+ /* ------------------------------------------------------------------ */
95
+ /* Row primitives — exercise the app-injection slots directly so the */
96
+ /* kit stays the verified source of truth for the thread-row rhythm. */
97
+ /* ------------------------------------------------------------------ */
98
+
99
+ const row: ThreadMessageData = {
100
+ id: "row-1",
101
+ fromName: "Jamie Chen",
102
+ fromEmail: "jamie@example.com",
103
+ toLabel: "Alex Rivera, you",
104
+ dateLabel: "Today, 09:11",
105
+ snippet: "Thanks — I'll circulate the deck this afternoon.",
106
+ bodyHtml:
107
+ "<p>Thanks for the summary. I'll circulate the deck this afternoon.</p>",
108
+ };
109
+
110
+ /** The collapsed row with the app's real trailing cluster (star + paperclip +
111
+ * date), an unread dot and a keyboard-focus ring — the slots the live
112
+ * MessageCard injects. The row is a `role="button"` div, so the interactive
113
+ * star in the trailing slot is a valid sibling control and not a `<button>`
114
+ * nested inside a `<button>` (#1232). Click the row to expand; the star stops
115
+ * propagation so it toggles without expanding. */
116
+ export const CollapsedRowComposed: StoryObj<typeof CollapsedMessage> = {
117
+ render: () => (
118
+ <div className="max-w-3xl border border-line">
119
+ <CollapsedMessage
120
+ message={row}
121
+ isUnread
122
+ onClick={() => alert("expand row-1")}
123
+ trailing={
124
+ <div className="flex shrink-0 items-center gap-1">
125
+ <button
126
+ type="button"
127
+ onClick={(e) => {
128
+ e.stopPropagation();
129
+ alert("toggle star");
130
+ }}
131
+ aria-label="Add star"
132
+ className="rounded p-0.5 text-fg-subtle hover:text-warning"
133
+ >
134
+ <Star className="size-3" />
135
+ </button>
136
+ <span className="text-2xs text-fg-subtle tabular-nums">
137
+ Today, 09:11
138
+ </span>
139
+ </div>
140
+ }
141
+ />
142
+ <CollapsedMessage
143
+ message={{ ...row, id: "row-2", fromName: "Alex Rivera" }}
144
+ isFocused
145
+ onClick={() => alert("expand row-2")}
146
+ trailing={
147
+ <div className="flex shrink-0 items-center gap-1">
148
+ <button
149
+ type="button"
150
+ onClick={(e) => {
151
+ e.stopPropagation();
152
+ alert("toggle star");
153
+ }}
154
+ aria-label="Remove star"
155
+ className="rounded p-0.5 text-warning"
156
+ >
157
+ <Star className="size-3 fill-current" />
158
+ </button>
159
+ <Paperclip className="size-3 text-fg-subtle" />
160
+ <span className="text-2xs text-fg-subtle tabular-nums">
161
+ Today, 08:42
162
+ </span>
163
+ </div>
164
+ }
165
+ />
166
+ </div>
167
+ ),
168
+ };
169
+
170
+ /** The expanded row with the app's injected slots: a sender badge, an
171
+ * indicators row, an action-menu placeholder and a custom recipient line —
172
+ * proving the kit composes app interactivity without importing app code. */
173
+ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
174
+ render: () => (
175
+ <div className="max-w-3xl border border-line">
176
+ <ExpandedMessage
177
+ message={row}
178
+ senderBadge={<span className="ml-1 text-positive text-xs">✓</span>}
179
+ to={<>to Alex Rivera and 2 others</>}
180
+ indicators={
181
+ <div className="mt-0.5 flex items-center gap-1">
182
+ <Star className="size-3.5 fill-current text-warning" />
183
+ <Paperclip className="size-3.5 text-fg-subtle" />
184
+ </div>
185
+ }
186
+ actionMenu={
187
+ <button type="button" className="rounded p-1 text-fg-subtle">
188
+
189
+ </button>
190
+ }
191
+ />
192
+ </div>
193
+ ),
194
+ };
@@ -0,0 +1,351 @@
1
+ import {
2
+ ChevronDown,
3
+ ChevronRight,
4
+ Info,
5
+ Search,
6
+ ShieldAlert,
7
+ SquarePen,
8
+ } from "lucide-react";
9
+ import { type ReactNode, useState } from "react";
10
+ import { cn } from "../lib/cn.js";
11
+ import { isSelfRowActivation } from "../lib/row-keyboard.js";
12
+ import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
13
+ import { Avatar } from "./avatar.js";
14
+ import { Button } from "./button.js";
15
+ import { Input } from "./input.js";
16
+ import { MailActionToolbar } from "./mail-action-toolbar.js";
17
+ import { MessageBodyView } from "./message-body-view.js";
18
+ import { ReadingPaneEmpty } from "./reading-pane-empty.js";
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* Pane 3: threaded reading pane */
22
+ /* ------------------------------------------------------------------ */
23
+
24
+ /**
25
+ * One collapsed thread row. Presentational and prop-driven: the kit owns the
26
+ * row rhythm (shared `px-2`/`lg:px-4` gutter, leading chevron, `md` avatar,
27
+ * fixed-width sender, snippet) and the app injects its interactivity via slots
28
+ * — `onClick` to expand, `isFocused`/`isUnread` for the keyboard ring and
29
+ * unread dot, and a `trailing` slot for the star/attachment/date cluster. With
30
+ * no slots it renders the fixture date label so the kit stories stand alone.
31
+ *
32
+ * The row is a `role="button"` div rather than a native `<button>` so the
33
+ * `trailing` slot can hold its own interactive controls (the star toggle) —
34
+ * `<button>` inside `<button>` is invalid HTML and throws a hydration error
35
+ * (#1232). Keyboard operation (Enter/Space) is wired by hand to keep the whole
36
+ * row expandable, guarded so a keypress bubbling up from the star doesn't also
37
+ * expand the row (`isSelfRowActivation`); the star stops click propagation so it
38
+ * toggles on its own.
39
+ */
40
+ export function CollapsedMessage({
41
+ message,
42
+ onClick,
43
+ isFocused,
44
+ isUnread,
45
+ trailing,
46
+ }: {
47
+ message: ThreadMessageData;
48
+ /** Expand handler. Omit for a static (story / SSR) row. */
49
+ onClick?: () => void;
50
+ /** Keyboard-focus indicator (inset ring), independent of hover. */
51
+ isFocused?: boolean;
52
+ /** Bolds the sender and overlays the unread dot on the avatar. */
53
+ isUnread?: boolean;
54
+ /**
55
+ * Trailing cluster (star button, attachment icon, date). Replaces the kit's
56
+ * default fixture date label so the app can inject its real interactive
57
+ * controls without the kit importing app/data code.
58
+ */
59
+ trailing?: ReactNode;
60
+ }) {
61
+ const content = (
62
+ <>
63
+ <ChevronRight className="size-3.5 shrink-0 text-fg-subtle" />
64
+ <div className="relative shrink-0">
65
+ <Avatar name={message.fromName} email={message.fromEmail} size="md" />
66
+ {isUnread && (
67
+ <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full border border-canvas bg-accent" />
68
+ )}
69
+ </div>
70
+ <span
71
+ className={cn(
72
+ "w-36 shrink-0 truncate text-sm",
73
+ isUnread ? "font-semibold text-fg" : "font-medium text-fg-muted",
74
+ )}
75
+ >
76
+ {message.fromName}
77
+ </span>
78
+ <span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
79
+ {message.snippet}
80
+ </span>
81
+ {trailing ?? (
82
+ <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
83
+ {message.dateLabel}
84
+ </span>
85
+ )}
86
+ </>
87
+ );
88
+
89
+ const className = cn(
90
+ "group flex h-section-row w-full items-center gap-3 border-b border-line px-2 text-left transition-colors hover:bg-surface-sunken lg:px-4",
91
+ isFocused && "bg-surface-sunken ring-1 ring-inset ring-accent/30",
92
+ );
93
+
94
+ if (!onClick) {
95
+ return <div className={className}>{content}</div>;
96
+ }
97
+
98
+ return (
99
+ // biome-ignore lint/a11y/useSemanticElements: a nested <button> (the star in the trailing slot) inside a native <button> is invalid HTML and throws a hydration error (#1232); a role="button" div keeps the whole row keyboard-expandable while letting the star be its own control
100
+ <div
101
+ role="button"
102
+ tabIndex={0}
103
+ aria-expanded={false}
104
+ onClick={onClick}
105
+ onKeyDown={(e) => {
106
+ if (!isSelfRowActivation(e)) return;
107
+ e.preventDefault();
108
+ onClick();
109
+ }}
110
+ className={cn(className, "cursor-pointer")}
111
+ >
112
+ {content}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ /**
118
+ * One expanded thread message. Presentational and prop-driven: the kit owns the
119
+ * header rhythm (leading chevron, `md` avatar, sender block) and the body
120
+ * spacing; the app injects its interactivity and real data via slots —
121
+ * `onHeaderClick` to collapse, `isFocused`/`isUnread`, a `senderBadge` after the
122
+ * name, `trailing` for the date, `to` for the recipient line, `indicators` for
123
+ * the star/attachment row, `actionMenu` for the kebab, and `body` for the real
124
+ * message body (loading/error/raw). With no `body` it renders the fixture
125
+ * `MessageBodyView` so the kit stories stand alone.
126
+ */
127
+ export function ExpandedMessage({
128
+ message,
129
+ warning,
130
+ onHeaderClick,
131
+ isFocused,
132
+ senderBadge,
133
+ trailing,
134
+ to,
135
+ indicators,
136
+ actionMenu,
137
+ actionBar,
138
+ body,
139
+ }: {
140
+ message: ThreadMessageData;
141
+ warning?: string;
142
+ /** Collapse handler on the sender block. Omit for a static row. */
143
+ onHeaderClick?: () => void;
144
+ isFocused?: boolean;
145
+ /** Rendered after the sender name (e.g. a trusted-sender badge). */
146
+ senderBadge?: ReactNode;
147
+ /** Trailing header content (date). Defaults to the fixture date label. */
148
+ trailing?: ReactNode;
149
+ /** Recipient line. `undefined` → "to {message.toLabel}"; `null` → hidden. */
150
+ to?: ReactNode;
151
+ /** Row under the header (star / attachment / unread dot). */
152
+ indicators?: ReactNode;
153
+ /** Per-message action menu (kebab), right-aligned in the header. */
154
+ actionMenu?: ReactNode;
155
+ /**
156
+ * Per-message action bar (reply / triage), rendered between the header and the
157
+ * body. The narrow-width reading view puts the message's verbs here so they
158
+ * belong to this message; collapsed rows pass none.
159
+ */
160
+ actionBar?: ReactNode;
161
+ /**
162
+ * The message body. Replaces the kit's fixture `MessageBodyView` so the app
163
+ * can inject its real data-driven body (loading / error / raw toggle).
164
+ */
165
+ body?: ReactNode;
166
+ }) {
167
+ const sender = (
168
+ <>
169
+ <div className="flex items-baseline justify-between gap-2">
170
+ <span className="text-sm font-semibold text-fg">
171
+ {message.fromName}
172
+ {senderBadge}
173
+ </span>
174
+ {trailing ?? (
175
+ <span className="text-2xs text-fg-subtle">{message.dateLabel}</span>
176
+ )}
177
+ </div>
178
+ <div className="text-xs text-fg-subtle">{message.fromEmail}</div>
179
+ {to !== null && (
180
+ <div className="text-xs text-fg-subtle">
181
+ {to ?? <>to {message.toLabel}</>}
182
+ </div>
183
+ )}
184
+ </>
185
+ );
186
+
187
+ return (
188
+ <div
189
+ className={cn(
190
+ "px-2 py-3 lg:px-4",
191
+ isFocused && "ring-1 ring-inset ring-accent/30",
192
+ )}
193
+ >
194
+ <div className="flex items-start gap-3">
195
+ <ChevronDown className="mt-1 size-3.5 shrink-0 text-fg-subtle" />
196
+ <Avatar name={message.fromName} email={message.fromEmail} size="md" />
197
+ <div className="min-w-0 flex-1">
198
+ {onHeaderClick ? (
199
+ <button
200
+ type="button"
201
+ onClick={onHeaderClick}
202
+ className="w-full text-left"
203
+ >
204
+ {sender}
205
+ </button>
206
+ ) : (
207
+ sender
208
+ )}
209
+ {indicators}
210
+ </div>
211
+ {actionMenu && <div className="shrink-0">{actionMenu}</div>}
212
+ </div>
213
+
214
+ {warning && (
215
+ <div className="mt-3 flex items-start gap-3 rounded-md bg-danger-soft p-3">
216
+ <ShieldAlert className="mt-0.5 size-4 shrink-0 text-danger" />
217
+ <div className="text-sm text-fg">
218
+ <span className="font-semibold text-danger">Caution: </span>
219
+ {warning}{" "}
220
+ <button type="button" className="text-accent underline">
221
+ Why?
222
+ </button>
223
+ </div>
224
+ </div>
225
+ )}
226
+
227
+ {actionBar && <div className="mt-3 -mx-2 lg:-mx-4">{actionBar}</div>}
228
+
229
+ {/* The body is injected by the app (real sanitize → classify →
230
+ sandboxed-iframe pipeline via its MessageBody). With no `body`
231
+ slot the kit renders MessageBodyView directly so Storybook shows
232
+ exactly what the app renders, not a divergent inline-HTML mock
233
+ (#940). `framed` fixtures map to the newsletter treatment (author
234
+ colors preserved); the rest render plain. */}
235
+ {body ?? (
236
+ <MessageBodyView
237
+ className="mt-3"
238
+ html={message.bodyHtml}
239
+ category={message.framed ? "newsletter" : "personal"}
240
+ allowImages
241
+ />
242
+ )}
243
+ </div>
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Message action toolbar on the pane-header datum: the reading pane's
249
+ * verbs (reply/reply-all/forward, delete/move/flag) plus search and
250
+ * compose, Apple Mail-style above the message area. Built on the shared
251
+ * `MailActionToolbar` so the buttons stay pressable with no thread open and
252
+ * explain inline rather than greying out (the never-disable tenet).
253
+ */
254
+ function MessageToolbar({
255
+ hasThread,
256
+ intelligenceOpen,
257
+ onToggleIntelligence,
258
+ showIntelligenceToggle,
259
+ }: {
260
+ hasThread: boolean;
261
+ intelligenceOpen?: boolean;
262
+ onToggleIntelligence?: () => void;
263
+ showIntelligenceToggle?: boolean;
264
+ }) {
265
+ const [hint, setHint] = useState<string | null>(null);
266
+ return (
267
+ <MailActionToolbar
268
+ hasThread={hasThread}
269
+ onUnavailable={() => setHint("Open a message first")}
270
+ unavailableHint={hint}
271
+ >
272
+ {/* Apple Mail geometry: search sits top-right over the message
273
+ area but still filters the current list / brief */}
274
+ <Input
275
+ icon={<Search className="size-4" />}
276
+ placeholder="Search mail"
277
+ className="h-8 w-64 min-w-40 shrink"
278
+ />
279
+ <span className="mx-1 h-4 w-px bg-line" aria-hidden />
280
+ <Button
281
+ variant="ghost"
282
+ size="sm"
283
+ icon={<SquarePen className="size-4" />}
284
+ title="Compose (⌘N)"
285
+ aria-label="Compose"
286
+ />
287
+ {showIntelligenceToggle && !intelligenceOpen && (
288
+ <Button
289
+ variant="ghost"
290
+ size="sm"
291
+ icon={<Info className="size-4" />}
292
+ onClick={onToggleIntelligence}
293
+ aria-label="Show intelligence sidebar"
294
+ />
295
+ )}
296
+ </MailActionToolbar>
297
+ );
298
+ }
299
+
300
+ export function ReadingPane({
301
+ thread,
302
+ intelligenceOpen,
303
+ onToggleIntelligence,
304
+ showIntelligenceToggle,
305
+ }: {
306
+ thread?: ThreadData;
307
+ intelligenceOpen?: boolean;
308
+ onToggleIntelligence?: () => void;
309
+ showIntelligenceToggle?: boolean;
310
+ }) {
311
+ return (
312
+ <article className="flex h-full w-full min-w-0 flex-col bg-canvas">
313
+ <MessageToolbar
314
+ hasThread={Boolean(thread)}
315
+ intelligenceOpen={intelligenceOpen}
316
+ onToggleIntelligence={onToggleIntelligence}
317
+ showIntelligenceToggle={showIntelligenceToggle}
318
+ />
319
+
320
+ {!thread ? (
321
+ <ReadingPaneEmpty />
322
+ ) : (
323
+ <div className="flex-1 overflow-y-auto">
324
+ {/* subject lives with the content, Apple Mail-style: once per
325
+ thread, scrolls with the message */}
326
+ <div className="border-b border-line px-5 pt-5 pb-3">
327
+ <h2 className="max-w-2xl text-lg font-semibold leading-snug text-fg">
328
+ {thread.subject}
329
+ </h2>
330
+ <p className="mt-1 text-2xs text-fg-subtle">
331
+ {thread.messages.length}{" "}
332
+ {thread.messages.length === 1 ? "message" : "messages"}
333
+ </p>
334
+ </div>
335
+
336
+ {thread.messages.map((message) =>
337
+ message.expanded ? (
338
+ <ExpandedMessage
339
+ key={message.id}
340
+ message={message}
341
+ warning={thread.warning}
342
+ />
343
+ ) : (
344
+ <CollapsedMessage key={message.id} message={message} />
345
+ ),
346
+ )}
347
+ </div>
348
+ )}
349
+ </article>
350
+ );
351
+ }