@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,178 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import type {
4
+ BriefCategoryFilter,
5
+ ThreadRowData,
6
+ ThreadSection,
7
+ } from "./app-shell-types.js";
8
+ import { BriefSections } from "./brief-sections.js";
9
+ import type { FilterSheetSource } from "./filter-sheet.js";
10
+ import { ComfortableRow } from "./message-row.js";
11
+
12
+ function newsletterRow(i: number): ThreadRowData {
13
+ return {
14
+ id: `n${i}`,
15
+ accountId: "a1",
16
+ fromName: `Digest ${i}`,
17
+ fromEmail: `digest${i}@news.example`,
18
+ subject: `This week, edition ${i}`,
19
+ snippet: "Stories you might have missed.",
20
+ timeLabel: "Thu",
21
+ isRead: true,
22
+ category: "newsletter",
23
+ };
24
+ }
25
+
26
+ const sections: ThreadSection[] = [
27
+ {
28
+ id: "flagged",
29
+ label: "Flagged",
30
+ threads: [
31
+ {
32
+ id: "f1",
33
+ accountId: "a1",
34
+ fromName: "Dana Lopez",
35
+ fromEmail: "dana@example.com",
36
+ subject: "Offsite logistics",
37
+ snippet: "Final headcount for the venue.",
38
+ timeLabel: "Tue",
39
+ isRead: false,
40
+ starred: true,
41
+ category: "personal",
42
+ },
43
+ ],
44
+ },
45
+ {
46
+ id: "personal",
47
+ label: "Personal",
48
+ threads: [
49
+ {
50
+ id: "p1",
51
+ accountId: "a1",
52
+ fromName: "Priya Nair",
53
+ fromEmail: "priya@example.com",
54
+ subject: "Design review tomorrow",
55
+ snippet: "Can we move it to 2pm? I have a conflict.",
56
+ timeLabel: "8:15",
57
+ isRead: false,
58
+ category: "personal",
59
+ },
60
+ ],
61
+ },
62
+ {
63
+ id: "transactional",
64
+ label: "Transactional",
65
+ threads: [
66
+ {
67
+ id: "x1",
68
+ accountId: "a1",
69
+ fromName: "Sam Okafor",
70
+ fromEmail: "sam@example.com",
71
+ subject: "Contract signed",
72
+ snippet: "Attaching the countersigned PDF.",
73
+ timeLabel: "9:01",
74
+ isRead: false,
75
+ hasAttachment: true,
76
+ category: "transactional",
77
+ },
78
+ ],
79
+ },
80
+ {
81
+ id: "newsletter",
82
+ label: "Newsletter",
83
+ threads: Array.from({ length: 14 }, (_, i) => newsletterRow(i + 1)),
84
+ },
85
+ ];
86
+
87
+ const meta: Meta<typeof BriefSections> = {
88
+ title: "Screens/Kit/BriefSections",
89
+ component: BriefSections,
90
+ parameters: { layout: "fullscreen" },
91
+ args: {
92
+ sections,
93
+ Row: ComfortableRow,
94
+ briefCategory: "all",
95
+ onSelectThread: () => undefined,
96
+ onSelectBriefCategory: () => undefined,
97
+ },
98
+ };
99
+ export default meta;
100
+
101
+ type Story = StoryObj<typeof BriefSections>;
102
+
103
+ export const Desktop: Story = {
104
+ render: (args) => (
105
+ <div className="flex h-screen w-96 flex-col border-r border-line">
106
+ <BriefSections {...args} />
107
+ </div>
108
+ ),
109
+ };
110
+
111
+ export const Mobile: Story = {
112
+ render: (args) => (
113
+ <div className="flex h-[844px] w-[390px] flex-col border border-line">
114
+ <BriefSections {...args} />
115
+ </div>
116
+ ),
117
+ };
118
+
119
+ /**
120
+ * (a) "All" scope: one capped section per category, each with its header. This
121
+ * is the cross-account aggregate where the section headers earn their keep.
122
+ */
123
+ export const AllScopeWithHeaders: Story = {
124
+ args: { briefCategory: "all" },
125
+ render: (args) => (
126
+ <div className="flex h-screen w-96 flex-col border-r border-line">
127
+ <BriefSections {...args} />
128
+ </div>
129
+ ),
130
+ };
131
+
132
+ /**
133
+ * (b) Single-category filter: narrowed to Newsletter, the list renders FLAT with
134
+ * NO section header — the header would be redundant once a single category is
135
+ * selected. This is the behavior the live brief now inherits from the kit.
136
+ */
137
+ export const SingleCategoryFlat: Story = {
138
+ args: { briefCategory: "newsletter" },
139
+ render: (args) => (
140
+ <div className="flex h-screen w-96 flex-col border-r border-line">
141
+ <BriefSections {...args} />
142
+ </div>
143
+ ),
144
+ };
145
+
146
+ const accountSources: FilterSheetSource[] = [
147
+ { id: "all", label: "All", active: true },
148
+ { id: "a1", label: "work", count: 3 },
149
+ { id: "a2", label: "personal", count: 8 },
150
+ ];
151
+
152
+ /**
153
+ * (c) Account-source filtering (n>1): the cross-account brief exposes an account
154
+ * pill row above the categories. The row only appears with more than one source.
155
+ * Selecting a source is single-select (encoded via each source's `active` flag).
156
+ */
157
+ export const AccountSources: Story = {
158
+ render: (args) => {
159
+ const [source, setSource] = useState("all");
160
+ const [category, setCategory] = useState<BriefCategoryFilter>("all");
161
+ return (
162
+ <div className="flex h-screen w-96 flex-col border-r border-line">
163
+ <BriefSections
164
+ {...args}
165
+ briefCategory={category}
166
+ onSelectBriefCategory={setCategory}
167
+ sources={accountSources.map((s) => ({
168
+ ...s,
169
+ active: s.id === source,
170
+ }))}
171
+ sourcesNote="+1 muted"
172
+ onSelectSource={setSource}
173
+ defaultExpanded
174
+ />
175
+ </div>
176
+ );
177
+ },
178
+ };
@@ -0,0 +1,201 @@
1
+ import { useState } from "react";
2
+ import type {
3
+ BriefCategoryFilter,
4
+ ThreadRowData,
5
+ ThreadSection,
6
+ } from "./app-shell-types.js";
7
+ import { briefCategories, categoryTone } from "./app-shell-types.js";
8
+ import { BriefSection } from "./brief-section.js";
9
+ import {
10
+ FilterSheet,
11
+ type FilterSheetCategory,
12
+ type FilterSheetFilter,
13
+ type FilterSheetSource,
14
+ } from "./filter-sheet.js";
15
+ import type { BriefRowComponent } from "./message-row.js";
16
+
17
+ /* Composable brief filters — each is an additive predicate over a thread row. */
18
+ type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
19
+
20
+ /* "Today" prefers the real `sentDate` timestamp; it falls back to the fixture
21
+ convention that same-day rows render a HH:MM timeLabel (fixtures carry no
22
+ sentDate). */
23
+ function isTodayRow(t: ThreadRowData): boolean {
24
+ if (t.sentDate != null) {
25
+ return new Date(t.sentDate).toDateString() === new Date().toDateString();
26
+ }
27
+ return /^\d{1,2}:\d{2}$/.test(t.timeLabel);
28
+ }
29
+
30
+ const briefFilterDefs: ReadonlyArray<{
31
+ id: BriefFilterId;
32
+ label: string;
33
+ match: (t: ThreadRowData) => boolean;
34
+ }> = [
35
+ { id: "unread", label: "Unread", match: (t) => !t.isRead },
36
+ {
37
+ id: "attachment",
38
+ label: "Has attachment",
39
+ match: (t) => !!t.hasAttachment,
40
+ },
41
+ {
42
+ id: "contacts",
43
+ label: "From contacts",
44
+ match: (t) => t.trust === "vip" || t.trust === "wellknown",
45
+ },
46
+ { id: "today", label: "Today", match: isTodayRow },
47
+ ];
48
+
49
+ /**
50
+ * The brief's attribute chips as plain `{ id, label }` (no predicates) — the
51
+ * single source the `briefFilterConfig` preset reuses so the live filter row and
52
+ * the preset can never diverge.
53
+ */
54
+ export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
55
+ ({ id, label }) => ({ id, label }),
56
+ );
57
+
58
+ export interface BriefSectionsProps {
59
+ sections: ThreadSection[];
60
+ briefCategory?: BriefCategoryFilter;
61
+ selectedThreadId?: string;
62
+ Row: BriefRowComponent;
63
+ onSelectThread?: (id: string) => void;
64
+ onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
65
+ /**
66
+ * Account/source pills, passed straight through to the FilterSheet. Selection
67
+ * is encoded per source via `active`; the row only renders when more than one
68
+ * source is supplied (the cross-account brief). Single-account views omit it.
69
+ */
70
+ sources?: FilterSheetSource[];
71
+ /** Note rendered alongside the source pills (e.g. "+1 muted"). */
72
+ sourcesNote?: string;
73
+ /** Called when the user selects a source/account pill. */
74
+ onSelectSource?: (id: string) => void;
75
+ /** Seeds the filter panel open on first render (stories / deep links). */
76
+ defaultExpanded?: boolean;
77
+ }
78
+
79
+ /**
80
+ * The daily-brief list body: category pills (single-select) + attribute chips
81
+ * (additive) + one capped section per category (see {@link BriefSection}). Owns
82
+ * its own filter state; the category axis is controlled via
83
+ * `briefCategory`/`onSelectBriefCategory`. Consumers pre-filter `sections`
84
+ * (e.g. by search) and pass a `Row` renderer; the web client reuses this so the
85
+ * real brief and the Storybook prototype stay in lockstep.
86
+ */
87
+ export function BriefSections({
88
+ sections,
89
+ briefCategory = "all",
90
+ selectedThreadId,
91
+ Row,
92
+ onSelectThread,
93
+ onSelectBriefCategory,
94
+ sources,
95
+ sourcesNote,
96
+ onSelectSource,
97
+ defaultExpanded = false,
98
+ }: BriefSectionsProps) {
99
+ const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
100
+ const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
101
+
102
+ const toggleFilter = (id: BriefFilterId) => {
103
+ setActive((prev) => {
104
+ const next = new Set(prev);
105
+ if (next.has(id)) next.delete(id);
106
+ else next.add(id);
107
+ return next;
108
+ });
109
+ };
110
+
111
+ const predicates = briefFilterDefs.filter((f) => active.has(f.id));
112
+ const matches = (t: ThreadRowData) =>
113
+ (briefCategory === "all" || t.category === briefCategory) &&
114
+ predicates.every((f) => f.match(t));
115
+
116
+ // One section per category only earns its keep at the "all" scope. Narrow to
117
+ // a single category and the headers are redundant: render a plain flat list.
118
+ const showSections = briefCategory === "all";
119
+
120
+ const filtered = sections
121
+ .map((section) => ({
122
+ ...section,
123
+ threads: section.threads.filter(matches),
124
+ }))
125
+ .filter((section) => section.threads.length > 0);
126
+
127
+ const flatRows = sections.flatMap((s) => s.threads).filter(matches);
128
+
129
+ const sheetCategories: FilterSheetCategory[] = briefCategories.map((cat) => ({
130
+ id: cat.id,
131
+ label: cat.label,
132
+ tone: cat.id === "all" ? "neutral" : categoryTone[cat.id],
133
+ }));
134
+
135
+ const sheetFilters = briefFilterChips;
136
+
137
+ const clearFilters = () => {
138
+ onSelectBriefCategory?.("all");
139
+ setActive(new Set());
140
+ };
141
+
142
+ const empty = showSections ? filtered.length === 0 : flatRows.length === 0;
143
+
144
+ const listBody = (
145
+ <>
146
+ {showSections ? (
147
+ filtered.map((section) => (
148
+ <BriefSection
149
+ key={section.id}
150
+ section={section}
151
+ Row={Row}
152
+ selectedThreadId={selectedThreadId}
153
+ onSelectThread={onSelectThread}
154
+ />
155
+ ))
156
+ ) : (
157
+ <div className="divide-y divide-line">
158
+ {flatRows.map((t) => (
159
+ <Row
160
+ key={t.id}
161
+ thread={t}
162
+ active={t.id === selectedThreadId}
163
+ onClick={() => onSelectThread?.(t.id)}
164
+ />
165
+ ))}
166
+ </div>
167
+ )}
168
+ {empty && (
169
+ <div className="px-row-inset py-6 text-center text-2xs text-fg-subtle">
170
+ No threads match these filters.
171
+ </div>
172
+ )}
173
+ </>
174
+ );
175
+
176
+ // One source of truth for both breakpoints: a click-to-expand Filters bar
177
+ // that pushes the list down. The desktop brief used to render three
178
+ // permanently-expanded pill rows above the list with two redundant "All"
179
+ // pills (#783); the FilterSheet renders a single Filters control and one
180
+ // category scope.
181
+ return (
182
+ <FilterSheet
183
+ categories={sheetCategories}
184
+ filters={sheetFilters}
185
+ sources={sources}
186
+ sourcesNote={sourcesNote}
187
+ selectedCategory={briefCategory}
188
+ activeFilters={active}
189
+ expanded={sheetExpanded}
190
+ onExpandedChange={setSheetExpanded}
191
+ onSelectCategory={(id) =>
192
+ onSelectBriefCategory?.(id as BriefCategoryFilter)
193
+ }
194
+ onSelectSource={onSelectSource}
195
+ onToggleFilter={(id) => toggleFilter(id as BriefFilterId)}
196
+ onClear={clearFilters}
197
+ >
198
+ {listBody}
199
+ </FilterSheet>
200
+ );
201
+ }
@@ -0,0 +1,48 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ type Variant = "primary" | "secondary" | "ghost" | "danger";
5
+ type Size = "sm" | "md";
6
+
7
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8
+ variant?: Variant;
9
+ size?: Size;
10
+ /** Optional leading icon (lucide-react element). */
11
+ icon?: ReactNode;
12
+ }
13
+
14
+ const base =
15
+ "inline-flex items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface disabled:opacity-50 disabled:pointer-events-none";
16
+
17
+ const variants: Record<Variant, string> = {
18
+ primary: "bg-accent text-accent-fg hover:bg-accent-hover",
19
+ secondary:
20
+ "bg-surface text-fg border border-line hover:bg-surface-sunken hover:border-line-strong",
21
+ ghost: "text-fg-muted hover:bg-surface-sunken hover:text-fg",
22
+ danger: "bg-danger text-accent-fg hover:opacity-90",
23
+ };
24
+
25
+ const sizes: Record<Size, string> = {
26
+ sm: "h-7 px-2.5 text-xs",
27
+ md: "h-9 px-3.5 text-sm",
28
+ };
29
+
30
+ export function Button({
31
+ variant = "primary",
32
+ size = "md",
33
+ icon,
34
+ className,
35
+ children,
36
+ ...props
37
+ }: ButtonProps) {
38
+ return (
39
+ <button
40
+ type="button"
41
+ className={cn(base, variants[variant], sizes[size], className)}
42
+ {...props}
43
+ >
44
+ {icon}
45
+ {children}
46
+ </button>
47
+ );
48
+ }
@@ -0,0 +1,48 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
5
+ /** Slightly lifted surface with a hairline border. */
6
+ raised?: boolean;
7
+ }
8
+
9
+ export function Card({ raised, className, ...props }: CardProps) {
10
+ return (
11
+ <div
12
+ className={cn(
13
+ "rounded-sm border border-line bg-surface",
14
+ raised ? "bg-surface-raised shadow-sm" : "",
15
+ className,
16
+ )}
17
+ {...props}
18
+ />
19
+ );
20
+ }
21
+
22
+ export function CardHeader({
23
+ className,
24
+ ...props
25
+ }: HTMLAttributes<HTMLDivElement>) {
26
+ return <div className={cn("px-4 pt-3 pb-2", className)} {...props} />;
27
+ }
28
+
29
+ export function CardTitle({
30
+ className,
31
+ ...props
32
+ }: HTMLAttributes<HTMLHeadingElement>) {
33
+ return (
34
+ <h3 className={cn("text-md font-semibold text-fg", className)} {...props} />
35
+ );
36
+ }
37
+
38
+ export function CardBody({
39
+ className,
40
+ ...props
41
+ }: HTMLAttributes<HTMLDivElement>) {
42
+ return (
43
+ <div
44
+ className={cn("px-4 pb-3 text-sm text-fg-muted", className)}
45
+ {...props}
46
+ />
47
+ );
48
+ }
@@ -0,0 +1,40 @@
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 { CategoryBadge, getCategoryLabel } from "./category-badge.js";
6
+
7
+ describe("getCategoryLabel", () => {
8
+ it("returns null for undefined (no badge)", () => {
9
+ assert.equal(getCategoryLabel(undefined), null);
10
+ });
11
+
12
+ it("returns null for 'personal' (default fallback, no badge)", () => {
13
+ assert.equal(getCategoryLabel("personal"), null);
14
+ });
15
+
16
+ it("maps each non-personal category to its label", () => {
17
+ assert.equal(getCategoryLabel("newsletter"), "newsletter");
18
+ assert.equal(getCategoryLabel("marketing"), "marketing");
19
+ assert.equal(getCategoryLabel("automated"), "notification");
20
+ assert.equal(getCategoryLabel("transactional"), "receipt");
21
+ assert.equal(getCategoryLabel("social"), "social");
22
+ });
23
+ });
24
+
25
+ describe("CategoryBadge", () => {
26
+ it("renders nothing for personal", () => {
27
+ assert.equal(
28
+ renderToString(createElement(CategoryBadge, { category: "personal" })),
29
+ "",
30
+ );
31
+ });
32
+
33
+ it("renders the label with an accessible category name", () => {
34
+ const html = renderToString(
35
+ createElement(CategoryBadge, { category: "newsletter" }),
36
+ );
37
+ assert.match(html, /newsletter/);
38
+ assert.match(html, /aria-label="Category: newsletter"/);
39
+ });
40
+ });
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { CategoryBadge, type MessageCategory } from "./category-badge.js";
3
+
4
+ const meta: Meta<typeof CategoryBadge> = {
5
+ title: "Mail/CategoryBadge",
6
+ component: CategoryBadge,
7
+ parameters: { layout: "centered" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof CategoryBadge>;
12
+
13
+ const categories: MessageCategory[] = [
14
+ "newsletter",
15
+ "marketing",
16
+ "automated",
17
+ "transactional",
18
+ "social",
19
+ ];
20
+
21
+ export const AllCategories: Story = {
22
+ render: () => (
23
+ <div className="flex flex-wrap items-center gap-2">
24
+ {categories.map((category) => (
25
+ <CategoryBadge key={category} category={category} size="md" />
26
+ ))}
27
+ </div>
28
+ ),
29
+ };
30
+
31
+ export const PersonalRendersNothing: Story = {
32
+ args: { category: "personal", size: "md" },
33
+ };
34
+
35
+ export const ListRowSize: Story = {
36
+ args: { category: "newsletter", size: "sm" },
37
+ };
@@ -0,0 +1,71 @@
1
+ import { cn } from "../lib/cn.js";
2
+
3
+ export type MessageCategory =
4
+ | "personal"
5
+ | "newsletter"
6
+ | "marketing"
7
+ | "automated"
8
+ | "transactional"
9
+ | "social";
10
+
11
+ /**
12
+ * Display label for a message category badge.
13
+ *
14
+ * Per EDD #232: `personal` is the default fallback and never renders a badge,
15
+ * so it has no entry here. `transactional` shows as "receipt" and `automated`
16
+ * shows as "notification" — wording chosen so the badge reads naturally next
17
+ * to a subject line.
18
+ */
19
+ const CATEGORY_LABELS: Record<Exclude<MessageCategory, "personal">, string> = {
20
+ newsletter: "newsletter",
21
+ marketing: "marketing",
22
+ automated: "notification",
23
+ transactional: "receipt",
24
+ social: "social",
25
+ };
26
+
27
+ export const getCategoryLabel = (
28
+ category: MessageCategory | undefined,
29
+ ): string | null => {
30
+ if (!category || category === "personal") return null;
31
+ return CATEGORY_LABELS[category];
32
+ };
33
+
34
+ export interface CategoryBadgeProps {
35
+ category: MessageCategory | undefined;
36
+ /** Larger size for the open-message header. List rows use the default. */
37
+ size?: "sm" | "md";
38
+ className?: string;
39
+ }
40
+
41
+ /**
42
+ * Inline category label for non-personal mail. Renders nothing for `personal`
43
+ * or absent values so the existing list-row layout is unaffected for the
44
+ * common case.
45
+ *
46
+ * Visual style matches the muted, low-emphasis aesthetic of the surrounding
47
+ * row chrome — no color tabs, no full-width pill backgrounds.
48
+ */
49
+ export const CategoryBadge = ({
50
+ category,
51
+ size = "sm",
52
+ className,
53
+ }: CategoryBadgeProps) => {
54
+ const label = getCategoryLabel(category);
55
+ if (!label) return null;
56
+
57
+ return (
58
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label provides useful context for assistive tech despite generic span role
59
+ <span
60
+ className={cn(
61
+ "inline-flex items-center rounded border border-line bg-surface-sunken/50 font-medium uppercase tracking-wide text-fg-muted shrink-0",
62
+ size === "sm" && "px-1.5 py-0 text-2xs leading-4",
63
+ size === "md" && "px-2 py-0.5 text-xs",
64
+ className,
65
+ )}
66
+ aria-label={`Category: ${label}`}
67
+ >
68
+ {label}
69
+ </span>
70
+ );
71
+ };
@@ -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 { Checkbox } from "./checkbox.js";
6
+
7
+ const noop = () => {};
8
+
9
+ describe("Checkbox", () => {
10
+ it("renders a real checkbox input", () => {
11
+ const html = renderToString(
12
+ createElement(Checkbox, {
13
+ "aria-label": "Select row",
14
+ checked: false,
15
+ onChange: noop,
16
+ }),
17
+ );
18
+ assert.match(html, /type="checkbox"/);
19
+ assert.match(html, /aria-label="Select row"/);
20
+ });
21
+
22
+ it("reflects the checked state on the input", () => {
23
+ const checked = renderToString(
24
+ createElement(Checkbox, {
25
+ "aria-label": "x",
26
+ checked: true,
27
+ onChange: noop,
28
+ }),
29
+ );
30
+ assert.match(checked, /checked=""/);
31
+
32
+ const unchecked = renderToString(
33
+ createElement(Checkbox, {
34
+ "aria-label": "x",
35
+ checked: false,
36
+ onChange: noop,
37
+ }),
38
+ );
39
+ assert.doesNotMatch(unchecked, /checked=""/);
40
+ });
41
+
42
+ it("wraps in a label with the touch target when given label text", () => {
43
+ const html = renderToString(
44
+ createElement(Checkbox, {
45
+ label: "Keep me posted",
46
+ checked: false,
47
+ onChange: noop,
48
+ }),
49
+ );
50
+ assert.match(html, /<label/);
51
+ assert.match(html, /min-h-11/);
52
+ assert.match(html, /Keep me posted/);
53
+ });
54
+
55
+ it("renders just the control when label and description are omitted", () => {
56
+ const html = renderToString(
57
+ createElement(Checkbox, {
58
+ "aria-label": "bare",
59
+ checked: false,
60
+ onChange: noop,
61
+ }),
62
+ );
63
+ assert.doesNotMatch(html, /<label/);
64
+ });
65
+ });