@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,24 @@
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 { AuthCard } from "./auth-card.js";
6
+
7
+ describe("AuthCard", () => {
8
+ it("carries data-auth-page so the Amplify overrides apply", () => {
9
+ const html = renderToString(createElement(AuthCard, null, "form"));
10
+ assert.match(html, /data-auth-page/);
11
+ });
12
+
13
+ it("renders the radial-gradient page frame", () => {
14
+ const html = renderToString(createElement(AuthCard, null, "form"));
15
+ assert.match(html, /radial-gradient/);
16
+ assert.match(html, /min-h-dvh/);
17
+ });
18
+
19
+ it("centres a max-width card column around its children", () => {
20
+ const html = renderToString(createElement(AuthCard, null, "SIGNIN"));
21
+ assert.match(html, /max-w-\[26rem\]/);
22
+ assert.match(html, /SIGNIN/);
23
+ });
24
+ });
@@ -0,0 +1,50 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AuthCard } from "./auth-card.js";
3
+ import { AuthFooter } from "./auth-footer.js";
4
+ import { AuthHero } from "./auth-hero.js";
5
+
6
+ const meta: Meta<typeof AuthCard> = {
7
+ title: "Auth/AuthCard",
8
+ component: AuthCard,
9
+ parameters: { layout: "fullscreen" },
10
+ };
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof AuthCard>;
14
+
15
+ const SampleForm = () => (
16
+ <div className="rounded-md border border-line bg-surface p-7 shadow-lg">
17
+ <div className="space-y-4">
18
+ <div className="space-y-1.5">
19
+ <span className="block text-sm font-medium text-fg">Email</span>
20
+ <div className="h-9 rounded-md border border-line bg-surface" />
21
+ </div>
22
+ <div className="space-y-1.5">
23
+ <span className="block text-sm font-medium text-fg">Password</span>
24
+ <div className="h-9 rounded-md border border-line bg-surface" />
25
+ </div>
26
+ <div className="h-9 rounded-md bg-accent" />
27
+ </div>
28
+ <AuthFooter />
29
+ </div>
30
+ );
31
+
32
+ export const Dark: Story = {
33
+ parameters: { theme: "dark" },
34
+ render: () => (
35
+ <AuthCard>
36
+ <AuthHero />
37
+ <SampleForm />
38
+ </AuthCard>
39
+ ),
40
+ };
41
+
42
+ export const Light: Story = {
43
+ parameters: { theme: "light" },
44
+ render: () => (
45
+ <AuthCard>
46
+ <AuthHero />
47
+ <SampleForm />
48
+ </AuthCard>
49
+ ),
50
+ };
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ export interface AuthCardProps {
5
+ children: ReactNode;
6
+ className?: string;
7
+ }
8
+
9
+ /**
10
+ * Sign-in page frame: full-height radial-gradient background that follows the
11
+ * active theme, centring a max-width card column. Carries `data-auth-page` so
12
+ * the co-located Amplify structural overrides (auth.css) apply to the
13
+ * Authenticator rendered inside. One source of truth for the auth page frame
14
+ * so it can't drift between the live shell and the story.
15
+ */
16
+ export function AuthCard({ children, className }: AuthCardProps) {
17
+ return (
18
+ <div
19
+ data-auth-page
20
+ className={cn(
21
+ "flex min-h-dvh w-full flex-col items-center justify-center overflow-y-auto bg-surface-sunken px-4 py-8 text-fg",
22
+ className,
23
+ )}
24
+ style={{
25
+ backgroundImage:
26
+ "radial-gradient(at 20% 0%, color-mix(in oklch, var(--accent) 8%, transparent) 0px, transparent 50%), radial-gradient(at 80% 100%, color-mix(in oklch, var(--accent) 8%, transparent) 0px, transparent 50%)",
27
+ }}
28
+ >
29
+ <div className="w-full max-w-[26rem]">{children}</div>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,17 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { renderToString } from "react-dom/server";
4
+ import { AuthFooter } from "./auth-footer.js";
5
+
6
+ describe("AuthFooter", () => {
7
+ it("renders a provider-neutral note by default", () => {
8
+ const html = renderToString(AuthFooter());
9
+ assert.match(html, /Secure sign-in/);
10
+ assert.doesNotMatch(html, /Cognito/);
11
+ });
12
+
13
+ it("renders a custom note", () => {
14
+ const html = renderToString(AuthFooter({ note: "Custom" }));
15
+ assert.match(html, /Custom/);
16
+ });
17
+ });
@@ -0,0 +1,23 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AuthFooter } from "./auth-footer.js";
3
+
4
+ const meta: Meta<typeof AuthFooter> = {
5
+ title: "Auth/AuthFooter",
6
+ component: AuthFooter,
7
+ parameters: { layout: "centered" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof AuthFooter>;
12
+
13
+ export const Dark: Story = {
14
+ parameters: { theme: "dark" },
15
+ };
16
+
17
+ export const Light: Story = {
18
+ parameters: { theme: "light" },
19
+ };
20
+
21
+ export const CognitoProvider: Story = {
22
+ args: { note: "Secure sign-in powered by AWS Cognito" },
23
+ };
@@ -0,0 +1,14 @@
1
+ export const COGNITO_FOOTER_NOTE = "Secure sign-in powered by AWS Cognito";
2
+
3
+ export interface AuthFooterProps {
4
+ note?: string;
5
+ }
6
+
7
+ export function AuthFooter(props?: AuthFooterProps) {
8
+ const note = props?.note ?? "Secure sign-in";
9
+ return (
10
+ <div className="text-center pt-5">
11
+ <p className="text-xs text-fg-muted">{note}</p>
12
+ </div>
13
+ );
14
+ }
@@ -0,0 +1,31 @@
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 { AuthHero } from "./auth-hero.js";
6
+
7
+ describe("AuthHero", () => {
8
+ it("renders the default wordmark, tagline, and envelope mark", () => {
9
+ const html = renderToString(createElement(AuthHero));
10
+ assert.match(html, /remit,/);
11
+ assert.match(html, /your email client in the cloud\./);
12
+ assert.match(html, /<svg/);
13
+ });
14
+
15
+ it("renders custom wordmark and tagline", () => {
16
+ const html = renderToString(
17
+ createElement(AuthHero, {
18
+ wordmark: "acme,",
19
+ tagline: "mail, reimagined.",
20
+ }),
21
+ );
22
+ assert.match(html, /acme,/);
23
+ assert.match(html, /mail, reimagined\./);
24
+ });
25
+
26
+ it("is NOT wrapped in an Amplify router slot — it floats above the card", () => {
27
+ const html = renderToString(createElement(AuthHero));
28
+ assert.doesNotMatch(html, /data-amplify-router/);
29
+ assert.doesNotMatch(html, /data-amplify/);
30
+ });
31
+ });
@@ -0,0 +1,19 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AuthHero } from "./auth-hero.js";
3
+
4
+ const meta: Meta<typeof AuthHero> = {
5
+ title: "Auth/AuthHero",
6
+ component: AuthHero,
7
+ parameters: { layout: "centered" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof AuthHero>;
12
+
13
+ export const Dark: Story = {
14
+ parameters: { theme: "dark" },
15
+ };
16
+
17
+ export const Light: Story = {
18
+ parameters: { theme: "light" },
19
+ };
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface AuthHeroProps {
4
+ wordmark?: string;
5
+ tagline?: string;
6
+ mark?: ReactNode;
7
+ }
8
+
9
+ const EnvelopeMark = () => (
10
+ <svg
11
+ width="36"
12
+ height="36"
13
+ viewBox="0 0 24 24"
14
+ fill="none"
15
+ stroke="currentColor"
16
+ strokeWidth="1.75"
17
+ strokeLinecap="round"
18
+ strokeLinejoin="round"
19
+ aria-hidden="true"
20
+ className="text-accent"
21
+ >
22
+ <rect x="3" y="5" width="18" height="14" rx="2" />
23
+ <path d="M3 7l9 6 9-6" />
24
+ </svg>
25
+ );
26
+
27
+ export function AuthHero({
28
+ wordmark = "remit,",
29
+ tagline = "your email client in the cloud.",
30
+ mark = <EnvelopeMark />,
31
+ }: AuthHeroProps) {
32
+ return (
33
+ <div className="text-center pb-6">
34
+ <div className="flex items-center justify-center pb-3">{mark}</div>
35
+ <h1 className="text-[1.75rem] font-semibold tracking-[-0.01em] text-fg">
36
+ {wordmark}
37
+ </h1>
38
+ <p className="mt-1 text-sm text-fg-muted">{tagline}</p>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,42 @@
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 { AutoMovedBadge } from "./auto-moved-badge.js";
6
+
7
+ describe("AutoMovedBadge", () => {
8
+ it("renders the label", () => {
9
+ const html = renderToString(
10
+ createElement(AutoMovedBadge, { label: "Moved from Junk by Remit" }),
11
+ );
12
+ assert.match(html, /Moved from Junk by Remit/);
13
+ });
14
+
15
+ it("renders no Undo action when onUndo is omitted", () => {
16
+ const html = renderToString(
17
+ createElement(AutoMovedBadge, { label: "Moved from Junk by Remit" }),
18
+ );
19
+ assert.doesNotMatch(html, /Undo/);
20
+ });
21
+
22
+ it("renders an Undo action when onUndo is provided", () => {
23
+ const html = renderToString(
24
+ createElement(AutoMovedBadge, {
25
+ label: "Moved from Junk by Remit",
26
+ onUndo: () => undefined,
27
+ }),
28
+ );
29
+ assert.match(html, /Undo/);
30
+ });
31
+
32
+ it("renders a custom undo label", () => {
33
+ const html = renderToString(
34
+ createElement(AutoMovedBadge, {
35
+ label: "Moved from Junk by Remit",
36
+ onUndo: () => undefined,
37
+ undoLabel: "Move back to Junk",
38
+ }),
39
+ );
40
+ assert.match(html, /Move back to Junk/);
41
+ });
42
+ });
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AutoMovedBadge } from "./auto-moved-badge.js";
3
+
4
+ const meta: Meta<typeof AutoMovedBadge> = {
5
+ title: "Mail/AutoMovedBadge",
6
+ component: AutoMovedBadge,
7
+ parameters: { layout: "centered" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof AutoMovedBadge>;
12
+
13
+ export const ListRow: Story = {
14
+ args: { label: "Moved from Junk by Remit", size: "sm" },
15
+ };
16
+
17
+ export const ReadingViewWithUndo: Story = {
18
+ args: {
19
+ label: "Moved from Junk by Remit",
20
+ size: "md",
21
+ onUndo: () => alert("Undo"),
22
+ },
23
+ };
24
+
25
+ export const MovedToJunk: Story = {
26
+ args: {
27
+ label: "Moved from Inbox by Remit",
28
+ size: "md",
29
+ onUndo: () => alert("Undo"),
30
+ },
31
+ };
32
+
33
+ export const WithoutUndoAction: Story = {
34
+ args: { label: "Moved from Junk by Remit", size: "md" },
35
+ };
36
+
37
+ export const SideBySide: Story = {
38
+ render: () => (
39
+ <div className="flex flex-col items-start gap-3">
40
+ <AutoMovedBadge label="Moved from Junk by Remit" size="sm" />
41
+ <AutoMovedBadge
42
+ label="Moved from Junk by Remit"
43
+ size="md"
44
+ onUndo={() => undefined}
45
+ />
46
+ </div>
47
+ ),
48
+ };
@@ -0,0 +1,57 @@
1
+ import { Undo2 } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { Badge } from "./badge.js";
4
+
5
+ export interface AutoMovedBadgeProps {
6
+ /** Plain-language description, e.g. "Moved from Junk by Remit". */
7
+ label: string;
8
+ /** `md` adds an inline Undo action for the open-message header; `sm` (list row) is icon + label only. */
9
+ size?: "sm" | "md";
10
+ /**
11
+ * Present only when undo is available for this message. Omit to render the
12
+ * indicator without an action (e.g. the target folder can't be resolved).
13
+ */
14
+ onUndo?: () => void;
15
+ undoLabel?: string;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Unobtrusive indicator that Remit auto-moved this message, with an optional
21
+ * inline one-click undo. Purely presentational — the label text and whether
22
+ * the move is still in effect (so the badge should render at all) are the
23
+ * caller's responsibility; this component has no notion of placement/mailbox
24
+ * state.
25
+ */
26
+ export function AutoMovedBadge({
27
+ label,
28
+ size = "sm",
29
+ onUndo,
30
+ undoLabel = "Undo",
31
+ className,
32
+ }: AutoMovedBadgeProps) {
33
+ return (
34
+ <Badge
35
+ tone="accent"
36
+ className={cn(size === "md" && "py-1 text-xs", className)}
37
+ >
38
+ <Undo2
39
+ className={cn(size === "sm" ? "size-3" : "size-3.5", "shrink-0")}
40
+ aria-hidden
41
+ />
42
+ <span>{label}</span>
43
+ {onUndo && (
44
+ <button
45
+ type="button"
46
+ onClick={(event) => {
47
+ event.stopPropagation();
48
+ onUndo();
49
+ }}
50
+ className="font-semibold underline decoration-dotted underline-offset-2 hover:decoration-solid"
51
+ >
52
+ {undoLabel}
53
+ </button>
54
+ )}
55
+ </Badge>
56
+ );
57
+ }
@@ -0,0 +1,60 @@
1
+ import { useMemo } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ export interface AvatarProps {
5
+ name: string;
6
+ email?: string;
7
+ size?: "sm" | "md" | "lg";
8
+ className?: string;
9
+ }
10
+
11
+ const sizes = {
12
+ sm: "size-7 text-2xs",
13
+ md: "size-9 text-sm",
14
+ lg: "size-11 text-md",
15
+ };
16
+
17
+ // Deterministic calm hue from a string — no random per-render flicker.
18
+ // Muted pastels of the retro palette: green, cyan, slate-teal, slate,
19
+ // mauve, sage. No warm coral — danger stays the only warm color in the UI.
20
+ const palette = [
21
+ "oklch(0.6 0.08 150)",
22
+ "oklch(0.6 0.08 185)",
23
+ "oklch(0.6 0.08 215)",
24
+ "oklch(0.6 0.07 250)",
25
+ "oklch(0.6 0.07 310)",
26
+ "oklch(0.62 0.07 110)",
27
+ ];
28
+
29
+ // Cream text reads on every palette entry in both themes; never pure white.
30
+ const avatarFg = "oklch(0.97 0.012 90)";
31
+
32
+ function initials(name: string): string {
33
+ const parts = name.trim().split(/\s+/).filter(Boolean);
34
+ if (parts.length === 0) return "?";
35
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
36
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
37
+ }
38
+
39
+ export function Avatar({ name, email, size = "md", className }: AvatarProps) {
40
+ const bg = useMemo(() => {
41
+ const key = email ?? name;
42
+ let h = 0;
43
+ for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
44
+ return palette[h % palette.length];
45
+ }, [name, email]);
46
+
47
+ return (
48
+ <span
49
+ className={cn(
50
+ "inline-flex shrink-0 items-center justify-center rounded-full font-semibold select-none",
51
+ sizes[size],
52
+ className,
53
+ )}
54
+ style={{ backgroundColor: bg, color: avatarFg }}
55
+ aria-hidden
56
+ >
57
+ {initials(name)}
58
+ </span>
59
+ );
60
+ }
@@ -0,0 +1,49 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ type Tone = "neutral" | "accent" | "positive" | "warning" | "danger";
5
+
6
+ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
7
+ tone?: Tone;
8
+ /** Render as a small filled dot + label instead of a pill. */
9
+ dot?: boolean;
10
+ }
11
+
12
+ const tones: Record<Tone, string> = {
13
+ neutral: "bg-surface-sunken text-fg-muted",
14
+ // informational chips ride the cyan secondary accent
15
+ accent: "bg-accent-2-soft text-accent-2",
16
+ positive: "bg-surface-sunken text-positive",
17
+ warning: "bg-surface-sunken text-warning",
18
+ danger: "bg-danger-soft text-danger",
19
+ };
20
+
21
+ const dotColors: Record<Tone, string> = {
22
+ neutral: "bg-fg-subtle",
23
+ accent: "bg-accent-2",
24
+ positive: "bg-positive",
25
+ warning: "bg-warning",
26
+ danger: "bg-danger",
27
+ };
28
+
29
+ export function Badge({
30
+ tone = "neutral",
31
+ dot,
32
+ className,
33
+ children,
34
+ ...props
35
+ }: BadgeProps) {
36
+ return (
37
+ <span
38
+ className={cn(
39
+ "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-2xs font-medium",
40
+ tones[tone],
41
+ className,
42
+ )}
43
+ {...props}
44
+ >
45
+ {dot && <span className={cn("size-1.5 rounded-full", dotColors[tone])} />}
46
+ {children}
47
+ </span>
48
+ );
49
+ }
@@ -0,0 +1,58 @@
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 { Banner } from "./banner.js";
6
+
7
+ describe("Banner", () => {
8
+ it("renders content with an alert role", () => {
9
+ const html = renderToString(
10
+ createElement(Banner, { tone: "warning" }, "heads up"),
11
+ );
12
+ assert.match(html, /role="alert"/);
13
+ assert.match(html, /heads up/);
14
+ });
15
+
16
+ it("applies the warning tone classes", () => {
17
+ const html = renderToString(
18
+ createElement(Banner, { tone: "warning" }, "x"),
19
+ );
20
+ assert.match(html, /text-warning/);
21
+ });
22
+
23
+ it("renders a dismiss button only when onDismiss is supplied", () => {
24
+ const withDismiss = renderToString(
25
+ createElement(Banner, { tone: "info", onDismiss: () => undefined }, "x"),
26
+ );
27
+ assert.match(withDismiss, /aria-label="Dismiss"/);
28
+
29
+ const withoutDismiss = renderToString(
30
+ createElement(Banner, { tone: "info" }, "x"),
31
+ );
32
+ assert.doesNotMatch(withoutDismiss, /aria-label="Dismiss"/);
33
+ });
34
+
35
+ it("soft success keeps a status role and a real dismiss button, not a raw glyph", () => {
36
+ const html = renderToString(
37
+ createElement(
38
+ Banner,
39
+ { tone: "success", variant: "soft", onDismiss: () => undefined },
40
+ "Account connected successfully.",
41
+ ),
42
+ );
43
+ assert.match(html, /role="status"/);
44
+ assert.match(html, /aria-label="Dismiss"/);
45
+ assert.doesNotMatch(html, /✕/);
46
+ });
47
+
48
+ it("soft danger reports an alert role", () => {
49
+ const html = renderToString(
50
+ createElement(
51
+ Banner,
52
+ { tone: "danger", variant: "soft" },
53
+ "Sign-in failed.",
54
+ ),
55
+ );
56
+ assert.match(html, /role="alert"/);
57
+ });
58
+ });
@@ -0,0 +1,92 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Banner } from "./banner.js";
3
+
4
+ const meta: Meta<typeof Banner> = {
5
+ title: "Auth/Banner",
6
+ component: Banner,
7
+ parameters: { layout: "padded" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof Banner>;
12
+
13
+ export const Info: Story = {
14
+ args: { tone: "info", children: "A new version is available." },
15
+ parameters: { theme: "dark" },
16
+ };
17
+
18
+ export const Success: Story = {
19
+ args: { tone: "success", children: "Your changes were saved." },
20
+ parameters: { theme: "dark" },
21
+ };
22
+
23
+ export const Warning: Story = {
24
+ args: {
25
+ tone: "warning",
26
+ children: (
27
+ <>
28
+ <strong className="font-semibold">Local dev</strong> — Cognito not
29
+ configured. Set VITE_COGNITO_USER_POOL_ID and VITE_COGNITO_CLIENT_ID in
30
+ .env.local to enable sign-in.
31
+ </>
32
+ ),
33
+ },
34
+ parameters: { theme: "dark" },
35
+ };
36
+
37
+ export const Danger: Story = {
38
+ args: { tone: "danger", children: "Something went wrong." },
39
+ parameters: { theme: "dark" },
40
+ };
41
+
42
+ export const Dismissible: Story = {
43
+ args: {
44
+ tone: "info",
45
+ children: "Dismiss me.",
46
+ onDismiss: () => undefined,
47
+ },
48
+ parameters: { theme: "dark" },
49
+ };
50
+
51
+ export const WarningLight: Story = {
52
+ args: {
53
+ tone: "warning",
54
+ children: (
55
+ <>
56
+ <strong className="font-semibold">Local dev</strong> — Cognito not
57
+ configured.
58
+ </>
59
+ ),
60
+ },
61
+ parameters: { theme: "light" },
62
+ };
63
+
64
+ export const SoftOauthSuccess: Story = {
65
+ name: "Soft — OAuth success",
66
+ args: {
67
+ tone: "success",
68
+ variant: "soft",
69
+ children: "Account connected successfully.",
70
+ onDismiss: () => undefined,
71
+ },
72
+ };
73
+
74
+ export const SoftOauthError: Story = {
75
+ name: "Soft — OAuth error",
76
+ args: {
77
+ tone: "danger",
78
+ variant: "soft",
79
+ children:
80
+ "Your organisation's admin needs to approve Remit. Ask your IT admin to grant the required permissions.",
81
+ onDismiss: () => undefined,
82
+ },
83
+ };
84
+
85
+ export const SoftNeutral: Story = {
86
+ name: "Soft — neutral",
87
+ args: {
88
+ tone: "info",
89
+ variant: "soft",
90
+ children: "Preferences are stored locally.",
91
+ },
92
+ };