@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,267 @@
1
+ import { createContext, type ReactNode, useContext, useState } from "react";
2
+ import {
3
+ type PaneLayout,
4
+ resolvePaneLayout,
5
+ useContainerWidth,
6
+ } from "./app-shell-types.js";
7
+ import { Dialog } from "./dialog.js";
8
+ import {
9
+ ResizableHandle,
10
+ ResizablePanel,
11
+ ResizablePanelGroup,
12
+ } from "./resizable.js";
13
+
14
+ /* ------------------------------------------------------------------ */
15
+ /* Slot-based shell: the same responsive 4-pane arrangement as */
16
+ /* AppShell but with ReactNode render slots instead of data props. */
17
+ /* The web-client passes live data-bound children; the kit preview */
18
+ /* passes its own static components via the data-driven AppShell. */
19
+ /* ------------------------------------------------------------------ */
20
+
21
+ export interface AppShellSlottedProps {
22
+ /**
23
+ * Nav sidebar content. Rendered as a persistent pane ≥1024px or as a
24
+ * dismissible slide-over below that. Required — the shell always has a nav.
25
+ */
26
+ nav: ReactNode;
27
+ /**
28
+ * List pane content: the message list, daily brief, outbox list, etc.
29
+ * Always visible at all widths (the single pane on narrow layouts).
30
+ */
31
+ list: ReactNode;
32
+ /**
33
+ * Reading pane content: the conversation view + toolbar. Appears ≥1024px.
34
+ * When absent the reading panel is not shown even if width allows.
35
+ */
36
+ reading?: ReactNode;
37
+ /**
38
+ * Intelligence rail content. Appears ≥1280px when `intelligenceOpen` is true.
39
+ * When absent the intelligence panel is not shown even if width allows.
40
+ */
41
+ intelligence?: ReactNode;
42
+ /** Show the intelligence pane (user preference). Defaults to true. */
43
+ intelligenceOpen?: boolean;
44
+ /**
45
+ * Guards the intelligence rail against showing with no open thread. The rail
46
+ * needs both width AND a thread; pass false to keep it closed even when wide
47
+ * and `intelligenceOpen` (an edge state). Defaults to true (caller asserts a
48
+ * thread, e.g. the live app only opens the rail with a selected message).
49
+ */
50
+ hasThread?: boolean;
51
+ /**
52
+ * List density. "compact" widens the list pane's default split (denser rows
53
+ * read better with more horizontal room); "comfortable" (default) uses the
54
+ * standard split. Only affects the two-pane (list + reading) default size.
55
+ */
56
+ density?: "comfortable" | "compact";
57
+ /**
58
+ * Header rendered only on narrow (< 1024px) widths — the mobile top bar.
59
+ * The web-client injects the app-specific bar (hamburger / title / search).
60
+ */
61
+ header?: ReactNode;
62
+ /**
63
+ * Content rendered outside the pane group (e.g., the compose FAB). Floats
64
+ * over the layout regardless of width.
65
+ */
66
+ overlay?: ReactNode;
67
+ /**
68
+ * Cold-load placeholder. Shown instead of the normal layout while `isLoading`
69
+ * is true.
70
+ */
71
+ skeleton?: ReactNode;
72
+ /** When true, shows `skeleton` instead of the panel layout. Defaults to false. */
73
+ isLoading?: boolean;
74
+ /**
75
+ * Seed width (px) for the container-query reflow before the first
76
+ * ResizeObserver measure (SSR / pre-mount).
77
+ */
78
+ initialWidth?: number;
79
+ /**
80
+ * Externally controlled nav-open state (narrow widths). When absent the
81
+ * shell manages it internally.
82
+ */
83
+ navOpen?: boolean;
84
+ /** Called when the nav slide-over should open. */
85
+ onOpenNav?: () => void;
86
+ /** Called when the nav slide-over should close. */
87
+ onCloseNav?: () => void;
88
+ }
89
+
90
+ /* ------------------------------------------------------------------ */
91
+ /* Pane-layout context */
92
+ /* ------------------------------------------------------------------ */
93
+
94
+ /** Published by `AppShellSlotted`; consumed by `useAppShellLayout`. */
95
+ export interface AppShellLayoutContext {
96
+ /** Current pane visibility derived from the shell's own-width. */
97
+ panes: PaneLayout;
98
+ /** Own-width in px; null before the first ResizeObserver measure. */
99
+ containerWidth: number | null;
100
+ /** True when the nav is a persistent pane (not a slide-over). */
101
+ showNavPane: boolean;
102
+ /** Open the nav slide-over. Call from list-header "folders" buttons. */
103
+ openNav: () => void;
104
+ /** True when the reading pane is active (width ≥ 1024px). */
105
+ showReadingPane: boolean;
106
+ /** True when the intelligence rail can show (width ≥ 1280px). */
107
+ showIntelligencePane: boolean;
108
+ }
109
+
110
+ const AppShellLayoutCtx = createContext<AppShellLayoutContext | null>(null);
111
+
112
+ /**
113
+ * Read the enclosing `AppShellSlotted`'s pane layout.
114
+ * Returns null outside of an `AppShellSlotted` (tests / Storybook).
115
+ */
116
+ export function useAppShellLayout(): AppShellLayoutContext | null {
117
+ return useContext(AppShellLayoutCtx);
118
+ }
119
+
120
+ /* ------------------------------------------------------------------ */
121
+ /* AppShellSlotted */
122
+ /* ------------------------------------------------------------------ */
123
+
124
+ export function AppShellSlotted({
125
+ nav,
126
+ list,
127
+ reading,
128
+ intelligence,
129
+ intelligenceOpen = true,
130
+ hasThread = true,
131
+ density = "comfortable",
132
+ header,
133
+ overlay,
134
+ skeleton,
135
+ isLoading = false,
136
+ initialWidth,
137
+ navOpen: navOpenProp,
138
+ onOpenNav,
139
+ onCloseNav,
140
+ }: AppShellSlottedProps) {
141
+ const [containerRef, containerWidth] = useContainerWidth(initialWidth);
142
+ const panes = resolvePaneLayout(containerWidth ?? 0);
143
+ const showNavPane = panes.nav;
144
+ const showReadingPane = panes.reading && Boolean(reading);
145
+ const isWide = panes.intelligence;
146
+
147
+ const [navOpenInternal, setNavOpenInternal] = useState(false);
148
+ const controlled = navOpenProp !== undefined;
149
+ const navOpen = controlled ? navOpenProp : navOpenInternal;
150
+ const openNav = () => {
151
+ if (!controlled) setNavOpenInternal(true);
152
+ onOpenNav?.();
153
+ };
154
+ const closeNav = () => {
155
+ if (!controlled) setNavOpenInternal(false);
156
+ onCloseNav?.();
157
+ };
158
+
159
+ const showIntelligencePanel =
160
+ isWide && intelligenceOpen && hasThread && Boolean(intelligence);
161
+
162
+ const layoutCtx: AppShellLayoutContext = {
163
+ panes,
164
+ containerWidth,
165
+ showNavPane,
166
+ openNav,
167
+ showReadingPane,
168
+ showIntelligencePane: isWide,
169
+ };
170
+
171
+ return (
172
+ <AppShellLayoutCtx.Provider value={layoutCtx}>
173
+ <div
174
+ ref={containerRef}
175
+ className="@container flex h-dvh w-full flex-col overflow-hidden bg-canvas font-sans text-fg"
176
+ >
177
+ {isLoading && skeleton ? (
178
+ skeleton
179
+ ) : (
180
+ <>
181
+ {/* Narrow top bar: rendered only when the nav is a slide-over
182
+ (< 1024px). Desktop has no slim bar. */}
183
+ {!showNavPane && header && <div className="shrink-0">{header}</div>}
184
+
185
+ <ResizablePanelGroup
186
+ direction="horizontal"
187
+ className="min-h-0 flex-1"
188
+ >
189
+ {showNavPane && (
190
+ <>
191
+ <ResizablePanel
192
+ id="nav"
193
+ order={1}
194
+ defaultSize={17}
195
+ minSize={12}
196
+ maxSize={24}
197
+ className="min-w-0"
198
+ >
199
+ {nav}
200
+ </ResizablePanel>
201
+ <ResizableHandle />
202
+ </>
203
+ )}
204
+
205
+ <ResizablePanel
206
+ id="list"
207
+ order={2}
208
+ defaultSize={
209
+ showReadingPane ? (density === "compact" ? 36 : 27) : 83
210
+ }
211
+ minSize={18}
212
+ maxSize={showReadingPane ? 48 : 88}
213
+ className="min-w-0"
214
+ >
215
+ {list}
216
+ </ResizablePanel>
217
+
218
+ {showReadingPane && (
219
+ <>
220
+ <ResizableHandle />
221
+ <ResizablePanel
222
+ id="reading"
223
+ order={3}
224
+ minSize={24}
225
+ className="min-w-0"
226
+ >
227
+ {reading}
228
+ </ResizablePanel>
229
+ </>
230
+ )}
231
+
232
+ {showIntelligencePanel && (
233
+ <>
234
+ <ResizableHandle />
235
+ <ResizablePanel
236
+ id="intelligence"
237
+ order={4}
238
+ defaultSize={21}
239
+ minSize={15}
240
+ maxSize={32}
241
+ className="min-w-0"
242
+ >
243
+ {intelligence}
244
+ </ResizablePanel>
245
+ </>
246
+ )}
247
+ </ResizablePanelGroup>
248
+
249
+ {/* Narrow nav: dismissible slide-over (#784). */}
250
+ {!showNavPane && (
251
+ <Dialog
252
+ open={navOpen}
253
+ onClose={closeNav}
254
+ title="Folders"
255
+ anchor="left"
256
+ >
257
+ {nav}
258
+ </Dialog>
259
+ )}
260
+
261
+ {overlay}
262
+ </>
263
+ )}
264
+ </div>
265
+ </AppShellLayoutCtx.Provider>
266
+ );
267
+ }
@@ -0,0 +1,328 @@
1
+ import {
2
+ type ReactElement,
3
+ type ReactNode,
4
+ type RefObject,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import type {
10
+ IntelligenceData,
11
+ SenderTrustLevel,
12
+ } from "./intelligence-panel.js";
13
+ import type { ListState } from "./message-list-state.js";
14
+
15
+ /** Pane-count thresholds, aligned to Tailwind `lg`/`xl`. The whole shell reflows
16
+ * by its own width: a single responsive surface, not per-device variants.
17
+ * < 1024px (below `lg`) → list pane only (phone + tablet portrait)
18
+ * 1024–1279px (`lg`) → list + reading pane (two-pane)
19
+ * ≥ 1280px (`xl`) → + intelligence rail (three-pane, when present)
20
+ */
21
+ export const READING_PANE_MIN_WIDTH = 1024;
22
+ export const INTELLIGENCE_MIN_WIDTH = 1280;
23
+
24
+ /** Which panes the shell shows at a given viewport width. Pure (no DOM) so the
25
+ * pane-count-by-width rule is testable. `intelligence` is gated additionally on
26
+ * there being intelligence data + an open thread at render time; this is the
27
+ * width ceiling only. */
28
+ export interface PaneLayout {
29
+ nav: boolean;
30
+ reading: boolean;
31
+ intelligence: boolean;
32
+ }
33
+
34
+ /**
35
+ * The single source of truth for the reflow rule:
36
+ * < readingPaneMinWidth → list alone (phone + tablet PORTRAIT) — no reading pane
37
+ * readingPaneMinWidth–intelligenceMinWidth → list + reading (tablet landscape / desktop)
38
+ * ≥ intelligenceMinWidth → + intelligence rail (widest)
39
+ * The persistent nav pane shares the reading-pane boundary; below it the nav is
40
+ * a slide-over, not a column.
41
+ *
42
+ * Both thresholds are configurable so a consumer can pass the desired pixel
43
+ * values without forking the layout logic. The kit `AppShell` uses the
44
+ * module-level defaults (1024 / 1280).
45
+ */
46
+ export function resolvePaneLayout(
47
+ width: number,
48
+ readingPaneMinWidth = READING_PANE_MIN_WIDTH,
49
+ intelligenceMinWidth = INTELLIGENCE_MIN_WIDTH,
50
+ ): PaneLayout {
51
+ const reading = width >= readingPaneMinWidth;
52
+ return {
53
+ nav: reading,
54
+ reading,
55
+ intelligence: width >= intelligenceMinWidth,
56
+ };
57
+ }
58
+
59
+ export type NarrowView = "list" | "message";
60
+
61
+ /** Seeds the narrow touch list's interaction state for stories / SSR, so it can
62
+ * render selection mode or a swipe-peeked row without a live gesture. */
63
+ export type TouchSeed = "selection" | "peek-trailing" | "peek-leading";
64
+
65
+ /**
66
+ * Measures an element's OWN width via ResizeObserver — a container query, not a
67
+ * viewport one. The shell reflows by the space it actually occupies (so it works
68
+ * embedded at any width, not just full-screen), and the pane count is derived
69
+ * from this with `resolvePaneLayout`. Returns [ref, width]; width is `null`
70
+ * until the first measure (SSR / pre-mount), where the shell renders list-only.
71
+ */
72
+ export function useContainerWidth(
73
+ seed?: number,
74
+ ): [RefObject<HTMLDivElement | null>, number | null] {
75
+ const ref = useRef<HTMLDivElement | null>(null);
76
+ const [width, setWidth] = useState<number | null>(seed ?? null);
77
+ useEffect(() => {
78
+ const el = ref.current;
79
+ if (!el || typeof ResizeObserver === "undefined") return;
80
+ const observer = new ResizeObserver((entries) => {
81
+ const entry = entries[0];
82
+ if (entry) setWidth(entry.contentRect.width);
83
+ });
84
+ observer.observe(el);
85
+ setWidth(el.getBoundingClientRect().width);
86
+ return () => observer.disconnect();
87
+ }, []);
88
+ return [ref, width];
89
+ }
90
+
91
+ /**
92
+ * System-folder role for a sidebar mailbox, resolved by the web-client adapter —
93
+ * the single detection path (see `mailbox-order.ts`). Absent = a custom user
94
+ * folder. The kit pins, orders, and icons system folders purely by this role and
95
+ * never inspects raw IMAP SPECIAL-USE strings itself. "inbox" is included even
96
+ * though INBOX carries no SPECIAL-USE attribute (it is matched by name).
97
+ */
98
+ export type NavMailboxRole =
99
+ | "inbox"
100
+ | "flagged"
101
+ | "drafts"
102
+ | "sent"
103
+ | "archive"
104
+ | "all"
105
+ | "junk"
106
+ | "trash";
107
+
108
+ export interface NavMailbox {
109
+ id: string;
110
+ name: string;
111
+ unseen?: number;
112
+ /** System-folder role (adapter-computed). Absent = a custom user folder. */
113
+ role?: NavMailboxRole;
114
+ /**
115
+ * Full mailbox path, surfaced as the row's `title` tooltip so a truncated or
116
+ * localized leaf name still reveals where it lives. Defaults to `name`.
117
+ */
118
+ fullPath?: string;
119
+ }
120
+
121
+ /** Per-account mailbox-load status, so the nav can show loading/error inline. */
122
+ export type NavAccountStatus = "loading" | "error" | "ready";
123
+
124
+ export interface NavAccount {
125
+ id: string;
126
+ label: string;
127
+ email: string;
128
+ /** Muted: excluded from unified views, still syncing. Rendered dimmed. */
129
+ muted?: boolean;
130
+ mailboxes: NavMailbox[];
131
+ /**
132
+ * Number of outbox messages pending send. When provided, an Outbox entry
133
+ * appears below the system mailbox list for this account.
134
+ */
135
+ outboxPending?: number;
136
+ /**
137
+ * Mailbox-load status. "ready" (default) renders the mailbox list; "loading"
138
+ * shows a placeholder; "error" shows a retry affordance via `onRetry`.
139
+ */
140
+ status?: NavAccountStatus;
141
+ /** Retry handler for the error state. */
142
+ onRetry?: () => void;
143
+ }
144
+
145
+ /**
146
+ * Renders a navigation entry as a real anchor so middle-click / open-in-new-tab
147
+ * / deep-linking / screen-reader link semantics all work. The web-client passes
148
+ * a router `<Link>` builder; when omitted, NavItem falls back to a button with
149
+ * programmatic `onSelectNav` (used by static stories / the AppShell preview).
150
+ */
151
+ export interface NavLinkRenderProps {
152
+ /** The nav id this entry targets ("brief", "outbox", or a mailbox id). */
153
+ navId: string;
154
+ className: string;
155
+ ariaLabel?: string;
156
+ title?: string;
157
+ children: ReactNode;
158
+ onClick?: () => void;
159
+ }
160
+
161
+ export type NavLinkComponent = (props: NavLinkRenderProps) => ReactElement;
162
+
163
+ export type ThreadCategory =
164
+ | "personal"
165
+ | "newsletter"
166
+ | "marketing"
167
+ | "automated"
168
+ | "transactional"
169
+ | "social";
170
+
171
+ /** "all" (no category narrowing) plus every content-type category. */
172
+ export type BriefCategoryFilter = ThreadCategory | "all";
173
+
174
+ /**
175
+ * Ordered content-type categories for the brief expando. Mirrors the generated
176
+ * `MessageCategory` enum (@remit/domain-enums); swap this local list for the
177
+ * generated enum's values once that package is importable from the UI build.
178
+ */
179
+ export const briefCategories: ReadonlyArray<{
180
+ id: BriefCategoryFilter;
181
+ label: string;
182
+ }> = [
183
+ { id: "all", label: "All" },
184
+ { id: "personal", label: "Personal" },
185
+ { id: "newsletter", label: "Newsletters" },
186
+ { id: "marketing", label: "Marketing" },
187
+ { id: "automated", label: "Automated" },
188
+ { id: "transactional", label: "Transactional" },
189
+ { id: "social", label: "Social" },
190
+ ];
191
+
192
+ export interface ThreadRowData {
193
+ id: string;
194
+ accountId: string;
195
+ /** Owning mailbox — used by the `in:` search-token filter. */
196
+ mailboxId?: string;
197
+ fromName: string;
198
+ fromEmail: string;
199
+ subject: string;
200
+ snippet: string;
201
+ timeLabel: string;
202
+ /** Unix epoch ms — used by the "Today" brief filter; absent in fixture rows. */
203
+ sentDate?: number;
204
+ isRead?: boolean;
205
+ hasAttachment?: boolean;
206
+ starred?: boolean;
207
+ trust?: SenderTrustLevel;
208
+ category?: ThreadCategory;
209
+ /** Number of messages when the row is a thread. */
210
+ messageCount?: number;
211
+ /** Authenticity heuristics flagged this row (DKIM/From mismatch). */
212
+ suspicious?: boolean;
213
+ }
214
+
215
+ export interface ThreadSection {
216
+ id: string;
217
+ /** Section label; omit for a flat list. */
218
+ label?: string;
219
+ threads: ThreadRowData[];
220
+ }
221
+
222
+ export interface ThreadMessageData {
223
+ id: string;
224
+ fromName: string;
225
+ fromEmail: string;
226
+ toLabel: string;
227
+ dateLabel: string;
228
+ snippet: string;
229
+ bodyHtml: string;
230
+ expanded?: boolean;
231
+ /**
232
+ * Designed HTML mail (newsletters/marketing): render the body inside a
233
+ * hairline content frame that hugs left. The email keeps its own colors
234
+ * inside the frame (never dark-inverted); the frame contains the
235
+ * brightness so a white 600px blast doesn't glow to the pane edge.
236
+ */
237
+ framed?: boolean;
238
+ }
239
+
240
+ export interface ThreadData {
241
+ subject: string;
242
+ messages: ThreadMessageData[];
243
+ /** Danger banner above the body (authenticity verdicts only). */
244
+ warning?: string;
245
+ }
246
+
247
+ export type Density = "comfortable" | "compact";
248
+
249
+ export interface AppShellProps {
250
+ accounts: NavAccount[];
251
+ /**
252
+ * Seed width (px) for the container-query reflow before the first
253
+ * ResizeObserver measure. Lets SSR / tests render the correct pane count
254
+ * without a live layout pass; once mounted, the observed own-width takes over.
255
+ */
256
+ initialWidth?: number;
257
+ /** "brief" or a mailbox id. */
258
+ selectedNavId: string;
259
+ briefUnseen?: number;
260
+ listTitle: string;
261
+ listMeta?: string;
262
+ sections: ThreadSection[];
263
+ /** Brief mode: collapsible section headers + a composable filter chip bar. */
264
+ briefFilters?: boolean;
265
+ /**
266
+ * Flat plain-mailbox list (no section labels, no chip bar) — the shape the
267
+ * live `$mailboxId` MessageList renders. Defaults to the sectioned brief.
268
+ */
269
+ flatList?: boolean;
270
+ /**
271
+ * Drives the list pane's state. "ready" (default) renders rows; the other
272
+ * states render the loading skeleton / empty / error surfaces in place of
273
+ * the rows, mirroring the live MessageList.
274
+ */
275
+ listState?: ListState;
276
+ /** Active search query — switches the empty state to its search variant. */
277
+ searchQuery?: string;
278
+ /**
279
+ * Specific failure detail for the error state. Surfaced verbatim under the
280
+ * generic headline so the failure is readable (ux.md fail-loud), not a bare
281
+ * "something went wrong". Omit to fall back to the generic copy.
282
+ */
283
+ errorMessage?: string;
284
+ /** Retry handler for the error state. */
285
+ onRetry?: () => void;
286
+ /** Report handler for the error state (the failure goes somewhere). */
287
+ onReportError?: () => void;
288
+ /**
289
+ * Content-type category filter for the brief (a separate axis from the
290
+ * in-list chips). Selecting one narrows the brief to that category.
291
+ */
292
+ briefCategory?: BriefCategoryFilter;
293
+ onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
294
+ selectedThreadId?: string;
295
+ thread?: ThreadData;
296
+ /**
297
+ * Seed for the narrow single-pane view (the list, or the dedicated message
298
+ * view). Lets a story / SSR open straight to a thread without a click. Only
299
+ * meaningful below the reading boundary; ignored once the reading pane fits.
300
+ */
301
+ initialNarrowView?: NarrowView;
302
+ /**
303
+ * Seed the narrow touch list directly in selection mode or with a
304
+ * swipe-peeked row, so a story / SSR can show those triage states statically.
305
+ * Only meaningful below the reading boundary; ignored once the reading pane
306
+ * fits or when the message view is showing.
307
+ */
308
+ initialTouchState?: TouchSeed;
309
+ intelligence?: IntelligenceData;
310
+ /** Pane 4 visible. Defaults to true when intelligence is present. */
311
+ intelligenceOpen?: boolean;
312
+ density?: Density;
313
+ onSelectNav?: (id: string) => void;
314
+ onSelectThread?: (id: string) => void;
315
+ onToggleIntelligence?: () => void;
316
+ }
317
+
318
+ export const categoryTone: Record<
319
+ ThreadCategory,
320
+ "neutral" | "accent" | "positive" | "warning"
321
+ > = {
322
+ personal: "accent",
323
+ newsletter: "neutral",
324
+ marketing: "neutral",
325
+ automated: "neutral",
326
+ transactional: "positive",
327
+ social: "warning",
328
+ };