@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.
- package/package.json +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- 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
|
+
};
|