@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
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@remit/ui",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "files": [
6
+ "src"
7
+ ],
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./tokens.css": "./src/tokens.css"
11
+ },
12
+ "scripts": {
13
+ "test": "npm run test:typecheck && npm run test:run",
14
+ "test:typecheck": "tsgo --noEmit",
15
+ "test:run": "node --import tsx --test 'src/**/*.test.ts'"
16
+ },
17
+ "dependencies": {
18
+ "@fontsource-variable/geist": "^5",
19
+ "@fontsource-variable/hanken-grotesk": "^5",
20
+ "@fontsource-variable/inter": "^5",
21
+ "clsx": "^2",
22
+ "dompurify": "^3.4.7",
23
+ "lucide-react": "^0.468",
24
+ "react-resizable-panels": "^2.1.9",
25
+ "react-simple-pull-to-refresh": "^1.3.4",
26
+ "tailwind-merge": "^2"
27
+ },
28
+ "peerDependencies": {
29
+ "react": "^19",
30
+ "react-dom": "^19"
31
+ },
32
+ "devDependencies": {
33
+ "@storybook/react": "^9",
34
+ "@types/dompurify": "*",
35
+ "@types/react": "^19",
36
+ "@types/react-dom": "^19",
37
+ "react": "^19",
38
+ "react-dom": "^19",
39
+ "typescript": "*"
40
+ },
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/remit-mail/reader.git",
48
+ "directory": "packages/ui"
49
+ }
50
+ }
@@ -0,0 +1,93 @@
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 {
6
+ AddressDisplay,
7
+ AddressList,
8
+ type EnvelopeAddress,
9
+ } from "./address-display.js";
10
+
11
+ const named: EnvelopeAddress = {
12
+ displayName: "Ada Lovelace",
13
+ normalizedEmail: "ada@example.com",
14
+ };
15
+ const bare: EnvelopeAddress = { normalizedEmail: "grace@example.com" };
16
+
17
+ describe("AddressDisplay", () => {
18
+ it("renders display name with the address in angle brackets", () => {
19
+ const html = renderToString(
20
+ createElement(AddressDisplay, { address: named }),
21
+ );
22
+ assert.match(html, /Ada Lovelace/);
23
+ assert.match(html, /ada@example.com/);
24
+ });
25
+
26
+ it("falls back to the bare email when no display name", () => {
27
+ const html = renderToString(
28
+ createElement(AddressDisplay, { address: bare }),
29
+ );
30
+ assert.match(html, /grace@example.com/);
31
+ });
32
+
33
+ it("shows the trusted badge only when asked", () => {
34
+ assert.match(
35
+ renderToString(
36
+ createElement(AddressDisplay, {
37
+ address: named,
38
+ showTrustedBadge: true,
39
+ }),
40
+ ),
41
+ /data-testid="trusted-sender-badge"/,
42
+ );
43
+ assert.doesNotMatch(
44
+ renderToString(createElement(AddressDisplay, { address: named })),
45
+ /trusted-sender-badge/,
46
+ );
47
+ });
48
+ });
49
+
50
+ describe("AddressList", () => {
51
+ it("renders nothing for an empty list", () => {
52
+ assert.equal(
53
+ renderToString(
54
+ createElement(AddressList, { label: "To", addresses: [] }),
55
+ ),
56
+ "",
57
+ );
58
+ });
59
+
60
+ it("shows the expand control only when there are more than three addresses", () => {
61
+ const three = [bare, bare, bare];
62
+ const four = [bare, bare, bare, bare];
63
+ assert.doesNotMatch(
64
+ renderToString(
65
+ createElement(AddressList, { label: "To", addresses: three }),
66
+ ),
67
+ /aria-label="Expand"/,
68
+ );
69
+ assert.match(
70
+ renderToString(
71
+ createElement(AddressList, { label: "To", addresses: four }),
72
+ ),
73
+ /aria-label="Expand"/,
74
+ );
75
+ });
76
+
77
+ it("renders the trusted badge for trusted From addresses when enabled", () => {
78
+ const trusted: EnvelopeAddress = {
79
+ ...named,
80
+ flags: { trusted: { value: true } },
81
+ };
82
+ assert.match(
83
+ renderToString(
84
+ createElement(AddressList, {
85
+ label: "From",
86
+ addresses: [trusted],
87
+ showTrustedBadge: true,
88
+ }),
89
+ ),
90
+ /trusted-sender-badge/,
91
+ );
92
+ });
93
+ });
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AddressList, type EnvelopeAddress } from "./address-display.js";
3
+
4
+ const meta: Meta<typeof AddressList> = {
5
+ title: "Mail/AddressList",
6
+ component: AddressList,
7
+ parameters: { layout: "padded" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof AddressList>;
12
+
13
+ const named = (name: string, email: string): EnvelopeAddress => ({
14
+ displayName: name,
15
+ normalizedEmail: email,
16
+ });
17
+
18
+ const trusted: EnvelopeAddress = {
19
+ ...named("Ada Lovelace", "ada@example.com"),
20
+ flags: { trusted: { value: true } },
21
+ };
22
+
23
+ export const SingleFromTrusted: Story = {
24
+ name: "From — trusted",
25
+ args: {
26
+ label: "From",
27
+ addresses: [trusted],
28
+ showTrustedBadge: true,
29
+ },
30
+ };
31
+
32
+ export const FewRecipients: Story = {
33
+ args: {
34
+ label: "To",
35
+ addresses: [
36
+ named("Grace Hopper", "grace@example.com"),
37
+ named("Alan Turing", "alan@example.com"),
38
+ ],
39
+ },
40
+ };
41
+
42
+ export const ManyRecipientsCollapsed: Story = {
43
+ name: "Many recipients (expandable)",
44
+ args: {
45
+ label: "To",
46
+ addresses: [
47
+ named("Grace Hopper", "grace@example.com"),
48
+ named("Alan Turing", "alan@example.com"),
49
+ named("Katherine Johnson", "katherine@example.com"),
50
+ named("Dorothy Vaughan", "dorothy@example.com"),
51
+ named("Mary Jackson", "mary@example.com"),
52
+ { normalizedEmail: "no-name@example.com" },
53
+ ],
54
+ },
55
+ };
@@ -0,0 +1,132 @@
1
+ import { BadgeCheck, ChevronDown, ChevronRight } from "lucide-react";
2
+ import { useState } from "react";
3
+
4
+ /**
5
+ * Minimal address shape the display needs. Structurally compatible with the
6
+ * generated `RemitImapEnvelopeAddressResponse` so call sites can pass envelope
7
+ * addresses straight through, but the kit owns no app types.
8
+ */
9
+ export interface EnvelopeAddress {
10
+ displayName?: string;
11
+ normalizedEmail: string;
12
+ flags?: {
13
+ trusted?: { value: boolean };
14
+ };
15
+ }
16
+
17
+ export interface AddressDisplayProps {
18
+ address: EnvelopeAddress;
19
+ /**
20
+ * When true, render a small "trusted sender" checkmark next to the
21
+ * address. Driven by `address.flags?.trusted?.value === true` upstream
22
+ * so the check is centralized in the parent that knows the role
23
+ * (e.g. only From-addresses get the indicator).
24
+ */
25
+ showTrustedBadge?: boolean;
26
+ }
27
+
28
+ export const AddressDisplay = ({
29
+ address,
30
+ showTrustedBadge = false,
31
+ }: AddressDisplayProps) => {
32
+ const badge = showTrustedBadge ? (
33
+ <BadgeCheck
34
+ className="inline-block size-4 ml-1 -mt-0.5 text-positive"
35
+ aria-label="Trusted sender"
36
+ data-testid="trusted-sender-badge"
37
+ />
38
+ ) : null;
39
+
40
+ if (address.displayName) {
41
+ return (
42
+ <span>
43
+ <span className="text-fg">{address.displayName}</span>
44
+ <span className="text-fg-muted">
45
+ {" "}
46
+ &lt;{address.normalizedEmail}&gt;
47
+ </span>
48
+ {badge}
49
+ </span>
50
+ );
51
+ }
52
+ return (
53
+ <span className="text-fg">
54
+ {address.normalizedEmail}
55
+ {badge}
56
+ </span>
57
+ );
58
+ };
59
+
60
+ export interface AddressListProps {
61
+ label: string;
62
+ addresses: EnvelopeAddress[];
63
+ /**
64
+ * When true, addresses with `flags.trusted.value === true` render a
65
+ * green checkmark. Only set this for the From row; trusted-status of
66
+ * To/Cc senders is irrelevant to the current viewer.
67
+ */
68
+ showTrustedBadge?: boolean;
69
+ }
70
+
71
+ export const AddressList = ({
72
+ label,
73
+ addresses,
74
+ showTrustedBadge = false,
75
+ }: AddressListProps) => {
76
+ const [expanded, setExpanded] = useState(false);
77
+
78
+ if (addresses.length === 0) return null;
79
+
80
+ const hasMany = addresses.length > 3;
81
+
82
+ const renderAddress = (addr: EnvelopeAddress) => {
83
+ const isTrusted = showTrustedBadge && addr.flags?.trusted?.value === true;
84
+ return <AddressDisplay address={addr} showTrustedBadge={isTrusted} />;
85
+ };
86
+
87
+ return (
88
+ <div className="flex gap-2 text-sm">
89
+ <span className="text-fg-muted shrink-0 w-12">{label}:</span>
90
+ <div className="flex-1 min-w-0">
91
+ {expanded ? (
92
+ <div className="flex flex-wrap gap-1">
93
+ {addresses.map((addr, idx) => (
94
+ // biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
95
+ <span key={idx}>
96
+ {renderAddress(addr)}
97
+ {idx < addresses.length - 1 && ", "}
98
+ </span>
99
+ ))}
100
+ </div>
101
+ ) : (
102
+ <div className="truncate">
103
+ {addresses.map((addr, idx) => (
104
+ // biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
105
+ <span key={idx}>
106
+ {renderAddress(addr)}
107
+ {idx < addresses.length - 1 && ", "}
108
+ </span>
109
+ ))}
110
+ </div>
111
+ )}
112
+ </div>
113
+ {hasMany && (
114
+ <button
115
+ type="button"
116
+ onClick={(e) => {
117
+ e.stopPropagation();
118
+ setExpanded(!expanded);
119
+ }}
120
+ className="shrink-0 text-fg-muted hover:text-fg transition-colors"
121
+ aria-label={expanded ? "Collapse" : "Expand"}
122
+ >
123
+ {expanded ? (
124
+ <ChevronDown className="h-4 w-4" />
125
+ ) : (
126
+ <ChevronRight className="h-4 w-4" />
127
+ )}
128
+ </button>
129
+ )}
130
+ </div>
131
+ );
132
+ };
@@ -0,0 +1,39 @@
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 { AddressTag } from "./address-tag.js";
6
+
7
+ describe("AddressTag", () => {
8
+ it("renders the display name when present", () => {
9
+ const html = renderToString(
10
+ createElement(AddressTag, {
11
+ email: "alex@example.com",
12
+ displayName: "Alex Rivera",
13
+ onRemove: () => {},
14
+ }),
15
+ );
16
+ assert.match(html, /Alex Rivera/);
17
+ });
18
+
19
+ it("falls back to the bare address when no display name", () => {
20
+ const html = renderToString(
21
+ createElement(AddressTag, {
22
+ email: "sam@example.com",
23
+ onRemove: () => {},
24
+ }),
25
+ );
26
+ assert.match(html, /sam@example\.com/);
27
+ });
28
+
29
+ it("labels the remove button with the address", () => {
30
+ const html = renderToString(
31
+ createElement(AddressTag, {
32
+ email: "alex@example.com",
33
+ displayName: "Alex Rivera",
34
+ onRemove: () => {},
35
+ }),
36
+ );
37
+ assert.match(html, /aria-label="Remove alex@example\.com"/);
38
+ });
39
+ });
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AddressTag } from "./address-tag.js";
3
+
4
+ const meta: Meta<typeof AddressTag> = {
5
+ title: "Compose/AddressTag",
6
+ component: AddressTag,
7
+ parameters: { layout: "centered" },
8
+ args: { onRemove: () => {} },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof AddressTag>;
13
+
14
+ export const EmailOnly: Story = {
15
+ args: { email: "alex@example.com" },
16
+ };
17
+
18
+ export const WithDisplayName: Story = {
19
+ args: { email: "alex@example.com", displayName: "Alex Rivera" },
20
+ };
21
+
22
+ export const LongAddress: Story = {
23
+ args: { email: "very.long.recipient.address@really-long-domain.example.com" },
24
+ };
25
+
26
+ export const Removable: Story = {
27
+ render: () => (
28
+ <div className="flex flex-wrap items-center gap-1">
29
+ <AddressTag
30
+ email="alex@example.com"
31
+ displayName="Alex Rivera"
32
+ onRemove={() => {}}
33
+ />
34
+ <AddressTag email="sam@example.com" onRemove={() => {}} />
35
+ <AddressTag
36
+ email="very.long.recipient.address@really-long-domain.example.com"
37
+ onRemove={() => {}}
38
+ />
39
+ </div>
40
+ ),
41
+ };
@@ -0,0 +1,38 @@
1
+ import { X } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ export interface AddressTagProps {
5
+ email: string;
6
+ displayName?: string;
7
+ onRemove: () => void;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * Dismissible recipient chip for compose fields and filter UIs. Shows the
13
+ * display name when present, falling back to the bare address, and truncates
14
+ * to keep long addresses from breaking the field layout.
15
+ */
16
+ export const AddressTag = ({
17
+ email,
18
+ displayName,
19
+ onRemove,
20
+ className,
21
+ }: AddressTagProps) => (
22
+ <span
23
+ className={cn(
24
+ "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent-2-soft text-sm max-w-[200px]",
25
+ className,
26
+ )}
27
+ >
28
+ <span className="truncate">{displayName || email}</span>
29
+ <button
30
+ type="button"
31
+ onClick={onRemove}
32
+ className="shrink-0 p-0.5 rounded-full hover:bg-fg-muted/20 transition-colors"
33
+ aria-label={`Remove ${email}`}
34
+ >
35
+ <X className="size-3" />
36
+ </button>
37
+ </span>
38
+ );
@@ -0,0 +1,23 @@
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 { AppPasswordHint } from "./app-password-hint.js";
6
+
7
+ describe("AppPasswordHint", () => {
8
+ it("renders a deep link when a url is provided", () => {
9
+ const html = renderToString(
10
+ createElement(AppPasswordHint, {
11
+ url: "https://support.apple.com/en-us/102654",
12
+ }),
13
+ );
14
+ assert.match(html, /href="https:\/\/support\.apple\.com\/en-us\/102654"/);
15
+ assert.match(html, /How to create an app password/);
16
+ });
17
+
18
+ it("renders the generic fallback when no url is provided", () => {
19
+ const html = renderToString(createElement(AppPasswordHint, {}));
20
+ assert.doesNotMatch(html, /<a /);
21
+ assert.match(html, /Check your provider.*help for app password/);
22
+ });
23
+ });
@@ -0,0 +1,19 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AppPasswordHint } from "./app-password-hint.js";
3
+
4
+ const meta: Meta<typeof AppPasswordHint> = {
5
+ title: "Onboarding/AppPasswordHint",
6
+ component: AppPasswordHint,
7
+ parameters: { layout: "padded" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof AppPasswordHint>;
12
+
13
+ export const KnownProvider: Story = {
14
+ args: { url: "https://support.apple.com/en-us/102654" },
15
+ };
16
+
17
+ export const UnknownProvider: Story = {
18
+ args: {},
19
+ };
@@ -0,0 +1,24 @@
1
+ export interface AppPasswordHintProps {
2
+ /** Resolved provider deep link; omit for the generic fallback. */
3
+ url?: string;
4
+ }
5
+
6
+ export function AppPasswordHint({ url }: AppPasswordHintProps) {
7
+ return (
8
+ <p className="text-2xs text-fg-subtle">
9
+ Stored encrypted, used only to connect to your mail server.{" "}
10
+ {url ? (
11
+ <a
12
+ href={url}
13
+ target="_blank"
14
+ rel="noopener noreferrer"
15
+ className="text-accent underline"
16
+ >
17
+ How to create an app password
18
+ </a>
19
+ ) : (
20
+ <span>Check your provider's help for app password instructions.</span>
21
+ )}
22
+ </p>
23
+ );
24
+ }
@@ -0,0 +1,128 @@
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 { AppShellSlottedProps } from "./app-shell-slotted.js";
6
+ import { AppShellSlotted } from "./app-shell-slotted.js";
7
+
8
+ const render = (overrides: Partial<AppShellSlottedProps> = {}) =>
9
+ renderToString(
10
+ createElement(AppShellSlotted, {
11
+ nav: createElement("div", { "data-testid": "nav" }, "Nav"),
12
+ list: createElement("div", { "data-testid": "list" }, "List"),
13
+ ...overrides,
14
+ }),
15
+ );
16
+
17
+ describe("AppShellSlotted slot rendering", () => {
18
+ it("renders the list slot unconditionally", () => {
19
+ const html = render({ initialWidth: 800 });
20
+ assert.match(html, /data-testid="list"/, "list slot is always rendered");
21
+ });
22
+
23
+ it("renders the reading slot at/above 1024px", () => {
24
+ const html = render({
25
+ initialWidth: 1100,
26
+ reading: createElement("div", { "data-testid": "reading" }, "Reading"),
27
+ });
28
+ assert.match(
29
+ html,
30
+ /data-testid="reading"/,
31
+ "reading pane appears at 1024px",
32
+ );
33
+ });
34
+
35
+ it("hides the reading slot below 1024px", () => {
36
+ const html = render({
37
+ initialWidth: 800,
38
+ reading: createElement("div", { "data-testid": "reading" }, "Reading"),
39
+ });
40
+ assert.doesNotMatch(
41
+ html,
42
+ /data-testid="reading"/,
43
+ "reading pane is absent below 1024px",
44
+ );
45
+ });
46
+
47
+ it("renders the intelligence slot at/above 1280px when intelligenceOpen", () => {
48
+ const html = render({
49
+ initialWidth: 1400,
50
+ reading: createElement("div", { "data-testid": "reading" }, "Reading"),
51
+ intelligence: createElement(
52
+ "div",
53
+ { "data-testid": "intelligence" },
54
+ "Intel",
55
+ ),
56
+ intelligenceOpen: true,
57
+ });
58
+ assert.match(
59
+ html,
60
+ /data-testid="intelligence"/,
61
+ "intelligence pane appears at 1280px",
62
+ );
63
+ });
64
+
65
+ it("hides the intelligence slot when intelligenceOpen is false", () => {
66
+ const html = render({
67
+ initialWidth: 1400,
68
+ reading: createElement("div", { "data-testid": "reading" }, "Reading"),
69
+ intelligence: createElement(
70
+ "div",
71
+ { "data-testid": "intelligence" },
72
+ "Intel",
73
+ ),
74
+ intelligenceOpen: false,
75
+ });
76
+ assert.doesNotMatch(
77
+ html,
78
+ /data-testid="intelligence"/,
79
+ "intelligence pane is hidden when closed",
80
+ );
81
+ });
82
+
83
+ it("renders the nav as a pane at/above 1024px (no folders trigger)", () => {
84
+ const html = render({ initialWidth: 1100 });
85
+ assert.match(html, /data-testid="nav"/, "nav is in the pane");
86
+ });
87
+
88
+ it("shows the header slot only below 1024px", () => {
89
+ const headerEl = createElement(
90
+ "div",
91
+ { "data-testid": "header" },
92
+ "Header",
93
+ );
94
+ const narrowHtml = render({ initialWidth: 800, header: headerEl });
95
+ const wideHtml = render({ initialWidth: 1100, header: headerEl });
96
+ assert.match(
97
+ narrowHtml,
98
+ /data-testid="header"/,
99
+ "header shows on narrow widths",
100
+ );
101
+ assert.doesNotMatch(
102
+ wideHtml,
103
+ /data-testid="header"/,
104
+ "header is hidden at/above 1024px",
105
+ );
106
+ });
107
+
108
+ it("renders the overlay regardless of width", () => {
109
+ const overlayEl = createElement("div", { "data-testid": "overlay" }, "FAB");
110
+ const narrowHtml = render({ initialWidth: 800, overlay: overlayEl });
111
+ const wideHtml = render({ initialWidth: 1100, overlay: overlayEl });
112
+ assert.match(narrowHtml, /data-testid="overlay"/, "overlay on narrow");
113
+ assert.match(wideHtml, /data-testid="overlay"/, "overlay on desktop");
114
+ });
115
+
116
+ it("renders the skeleton when isLoading is true", () => {
117
+ const html = render({
118
+ isLoading: true,
119
+ skeleton: createElement("div", { "data-testid": "skeleton" }, "Loading"),
120
+ });
121
+ assert.match(html, /data-testid="skeleton"/, "skeleton is rendered");
122
+ assert.doesNotMatch(
123
+ html,
124
+ /data-testid="list"/,
125
+ "list is hidden while loading",
126
+ );
127
+ });
128
+ });