@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,148 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
6
|
+
import {
|
|
7
|
+
CollapsedMessage,
|
|
8
|
+
ExpandedMessage,
|
|
9
|
+
ReadingPane,
|
|
10
|
+
} from "./reading-pane.js";
|
|
11
|
+
|
|
12
|
+
const message: ThreadMessageData = {
|
|
13
|
+
id: "m-1",
|
|
14
|
+
fromName: "Jamie Chen",
|
|
15
|
+
fromEmail: "jamie@example.com",
|
|
16
|
+
toLabel: "Alex Rivera, you",
|
|
17
|
+
dateLabel: "Today, 09:11",
|
|
18
|
+
snippet: "Circulating the deck this afternoon.",
|
|
19
|
+
bodyHtml: "<p>body</p>",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const thread: ThreadData = {
|
|
23
|
+
subject: "Q3 planning notes",
|
|
24
|
+
messages: [
|
|
25
|
+
{
|
|
26
|
+
id: "msg-1",
|
|
27
|
+
fromName: "Alex Rivera",
|
|
28
|
+
fromEmail: "alex@example.com",
|
|
29
|
+
toLabel: "you",
|
|
30
|
+
dateLabel: "Today, 09:11",
|
|
31
|
+
snippet: "Here's where we landed after the call.",
|
|
32
|
+
bodyHtml: "<p>The roadmap stands and the budget is locked.</p>",
|
|
33
|
+
expanded: true,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("ReadingPane", () => {
|
|
39
|
+
it("renders the subject and the expanded message header with a thread", () => {
|
|
40
|
+
const html = renderToString(createElement(ReadingPane, { thread }));
|
|
41
|
+
assert.match(html, /Q3 planning notes/);
|
|
42
|
+
// The email body now renders through the kit's sanitize → sandboxed
|
|
43
|
+
// IsolatedEmailFrame pipeline (#940), which needs a DOM and so emits no
|
|
44
|
+
// body text under SSR. Assert the expanded message's sender header
|
|
45
|
+
// instead; body rendering is covered by the sanitizer + MessageBodyView
|
|
46
|
+
// tests.
|
|
47
|
+
assert.match(html, /Alex Rivera/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders the empty state with no thread", () => {
|
|
51
|
+
const html = renderToString(createElement(ReadingPane, {}));
|
|
52
|
+
assert.doesNotMatch(html, /Q3 planning notes/);
|
|
53
|
+
assert.match(html, /Select a thread to read/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders the toolbar with the search input", () => {
|
|
57
|
+
const html = renderToString(createElement(ReadingPane, { thread }));
|
|
58
|
+
assert.match(html, /Search mail/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("CollapsedMessage row slots (#945)", () => {
|
|
63
|
+
it("renders the fixture date label when no trailing slot is given", () => {
|
|
64
|
+
const html = renderToString(createElement(CollapsedMessage, { message }));
|
|
65
|
+
assert.match(html, /Today, 09:11/);
|
|
66
|
+
assert.match(html, /Jamie Chen/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders the app's trailing cluster instead of the date label", () => {
|
|
70
|
+
const html = renderToString(
|
|
71
|
+
createElement(CollapsedMessage, {
|
|
72
|
+
message,
|
|
73
|
+
trailing: createElement("span", null, "STAR-DATE-SLOT"),
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
assert.match(html, /STAR-DATE-SLOT/);
|
|
77
|
+
assert.doesNotMatch(html, /Today, 09:11/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shows the unread dot and bolds the sender when isUnread", () => {
|
|
81
|
+
const html = renderToString(
|
|
82
|
+
createElement(CollapsedMessage, { message, isUnread: true }),
|
|
83
|
+
);
|
|
84
|
+
assert.match(html, /font-semibold/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("CollapsedMessage nested-button fix (#1232)", () => {
|
|
89
|
+
it("renders the clickable row as a role=button div so a trailing button doesn't nest inside a button", () => {
|
|
90
|
+
const html = renderToString(
|
|
91
|
+
createElement(CollapsedMessage, {
|
|
92
|
+
message,
|
|
93
|
+
onClick: () => {},
|
|
94
|
+
trailing: createElement(
|
|
95
|
+
"button",
|
|
96
|
+
{ type: "button", "aria-label": "Add star" },
|
|
97
|
+
"star",
|
|
98
|
+
),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
// The row itself is a div, keyboard-operable via role/tabindex — not a
|
|
102
|
+
// native <button>. The only <button> in the output is the trailing star.
|
|
103
|
+
assert.match(html, /^<div/);
|
|
104
|
+
assert.match(html, /role="button"/);
|
|
105
|
+
assert.match(html, /tabindex="0"/);
|
|
106
|
+
assert.match(html, /aria-expanded="false"/);
|
|
107
|
+
assert.equal(html.match(/<button/g)?.length ?? 0, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("renders a static row (no onClick) with no button role and no tabindex", () => {
|
|
111
|
+
const html = renderToString(createElement(CollapsedMessage, { message }));
|
|
112
|
+
assert.doesNotMatch(html, /role="button"/);
|
|
113
|
+
assert.doesNotMatch(html, /tabindex=/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("ExpandedMessage row slots (#945)", () => {
|
|
118
|
+
it("injects the app body slot instead of the fixture MessageBodyView", () => {
|
|
119
|
+
const html = renderToString(
|
|
120
|
+
createElement(ExpandedMessage, {
|
|
121
|
+
message,
|
|
122
|
+
body: createElement("div", null, "REAL-MESSAGE-BODY"),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
assert.match(html, /REAL-MESSAGE-BODY/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("renders the sender badge, action menu and recipient slots", () => {
|
|
129
|
+
const html = renderToString(
|
|
130
|
+
createElement(ExpandedMessage, {
|
|
131
|
+
message,
|
|
132
|
+
senderBadge: createElement("span", null, "BADGE"),
|
|
133
|
+
actionMenu: createElement("span", null, "ACTION-MENU"),
|
|
134
|
+
to: createElement("span", null, "TO-LINE"),
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
assert.match(html, /BADGE/);
|
|
138
|
+
assert.match(html, /ACTION-MENU/);
|
|
139
|
+
assert.match(html, /TO-LINE/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("hides the recipient line when `to` is null", () => {
|
|
143
|
+
const html = renderToString(
|
|
144
|
+
createElement(ExpandedMessage, { message, to: null }),
|
|
145
|
+
);
|
|
146
|
+
assert.doesNotMatch(html, /to Alex Rivera/);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Paperclip, Star } from "lucide-react";
|
|
3
|
+
import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
4
|
+
import {
|
|
5
|
+
CollapsedMessage,
|
|
6
|
+
ExpandedMessage,
|
|
7
|
+
ReadingPane,
|
|
8
|
+
} from "./reading-pane.js";
|
|
9
|
+
|
|
10
|
+
const thread: ThreadData = {
|
|
11
|
+
subject: "Q3 planning notes",
|
|
12
|
+
messages: [
|
|
13
|
+
{
|
|
14
|
+
id: "msg-1",
|
|
15
|
+
fromName: "Alex Rivera",
|
|
16
|
+
fromEmail: "alex@example.com",
|
|
17
|
+
toLabel: "you",
|
|
18
|
+
dateLabel: "Yesterday, 14:02",
|
|
19
|
+
snippet: "Here's where we landed after the call…",
|
|
20
|
+
bodyHtml:
|
|
21
|
+
"<p>Here's where we landed after the call. The roadmap stands.</p>",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "msg-2",
|
|
25
|
+
fromName: "Jamie Chen",
|
|
26
|
+
fromEmail: "jamie@example.com",
|
|
27
|
+
toLabel: "Alex Rivera, you",
|
|
28
|
+
dateLabel: "Today, 09:11",
|
|
29
|
+
snippet: "Thanks — I'll circulate the deck this afternoon.",
|
|
30
|
+
bodyHtml:
|
|
31
|
+
"<p>Thanks for the summary. I'll circulate the deck this afternoon and follow up with finance.</p><ul><li>Confirm headcount</li><li>Lock the budget</li></ul>",
|
|
32
|
+
expanded: true,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// A designed (framed) newsletter so the Screens story exercises the REAL
|
|
38
|
+
// renderer — sanitized + sandboxed iframe, flush layout, #727 scale-to-fit —
|
|
39
|
+
// rather than a plain inline paragraph (#940). The fixed 600px table is the
|
|
40
|
+
// kind of markup that overflowed a phone before #727.
|
|
41
|
+
const newsletterThread: ThreadData = {
|
|
42
|
+
subject: "Node Weekly — Issue 540",
|
|
43
|
+
messages: [
|
|
44
|
+
{
|
|
45
|
+
id: "nl-1",
|
|
46
|
+
fromName: "Node Weekly",
|
|
47
|
+
fromEmail: "news@nodeweekly.example",
|
|
48
|
+
toLabel: "you",
|
|
49
|
+
dateLabel: "Today, 08:00",
|
|
50
|
+
snippet: "Node.js 24 hits LTS, and more…",
|
|
51
|
+
framed: true,
|
|
52
|
+
expanded: true,
|
|
53
|
+
bodyHtml: `<table width="600" cellpadding="0" cellspacing="0" style="margin:0 auto;border-collapse:collapse;">
|
|
54
|
+
<tr><td width="600" style="width:600px;background:#83cd29;padding:24px;font-family:Helvetica,Arial,sans-serif;color:#ffffff;">
|
|
55
|
+
<h1 style="margin:0;font-size:26px;">Node Weekly</h1>
|
|
56
|
+
<p style="margin:4px 0 0;font-size:14px;">Issue 540</p>
|
|
57
|
+
</td></tr>
|
|
58
|
+
<tr><td width="600" style="width:600px;padding:24px;font-family:Georgia,serif;color:#1a1a1a;">
|
|
59
|
+
<h2 style="font-size:18px;color:#111;">Node.js 24 hits LTS</h2>
|
|
60
|
+
<p>The permission model graduated from experimental and the test runner picked up snapshot testing.</p>
|
|
61
|
+
<p><a href="https://example.com/issue/540" style="color:#43853d;">Read the full issue →</a></p>
|
|
62
|
+
</td></tr>
|
|
63
|
+
</table>`,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const meta: Meta<typeof ReadingPane> = {
|
|
69
|
+
title: "Screens/Kit/ReadingPane",
|
|
70
|
+
component: ReadingPane,
|
|
71
|
+
parameters: { layout: "fullscreen" },
|
|
72
|
+
render: (args) => (
|
|
73
|
+
<div className="h-screen border border-line">
|
|
74
|
+
<ReadingPane {...args} />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
};
|
|
78
|
+
export default meta;
|
|
79
|
+
|
|
80
|
+
type Story = StoryObj<typeof ReadingPane>;
|
|
81
|
+
|
|
82
|
+
export const WithThread: Story = { args: { thread } };
|
|
83
|
+
|
|
84
|
+
/** A designed newsletter rendered through the real sanitize → sandboxed-iframe
|
|
85
|
+
* pipeline — the same rendering the live app shows (#940). */
|
|
86
|
+
export const Newsletter: Story = { args: { thread: newsletterThread } };
|
|
87
|
+
|
|
88
|
+
export const Empty: Story = { args: { thread: undefined } };
|
|
89
|
+
|
|
90
|
+
export const WithIntelligenceToggle: Story = {
|
|
91
|
+
args: { thread, showIntelligenceToggle: true, intelligenceOpen: false },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/* ------------------------------------------------------------------ */
|
|
95
|
+
/* Row primitives — exercise the app-injection slots directly so the */
|
|
96
|
+
/* kit stays the verified source of truth for the thread-row rhythm. */
|
|
97
|
+
/* ------------------------------------------------------------------ */
|
|
98
|
+
|
|
99
|
+
const row: ThreadMessageData = {
|
|
100
|
+
id: "row-1",
|
|
101
|
+
fromName: "Jamie Chen",
|
|
102
|
+
fromEmail: "jamie@example.com",
|
|
103
|
+
toLabel: "Alex Rivera, you",
|
|
104
|
+
dateLabel: "Today, 09:11",
|
|
105
|
+
snippet: "Thanks — I'll circulate the deck this afternoon.",
|
|
106
|
+
bodyHtml:
|
|
107
|
+
"<p>Thanks for the summary. I'll circulate the deck this afternoon.</p>",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The collapsed row with the app's real trailing cluster (star + paperclip +
|
|
111
|
+
* date), an unread dot and a keyboard-focus ring — the slots the live
|
|
112
|
+
* MessageCard injects. The row is a `role="button"` div, so the interactive
|
|
113
|
+
* star in the trailing slot is a valid sibling control and not a `<button>`
|
|
114
|
+
* nested inside a `<button>` (#1232). Click the row to expand; the star stops
|
|
115
|
+
* propagation so it toggles without expanding. */
|
|
116
|
+
export const CollapsedRowComposed: StoryObj<typeof CollapsedMessage> = {
|
|
117
|
+
render: () => (
|
|
118
|
+
<div className="max-w-3xl border border-line">
|
|
119
|
+
<CollapsedMessage
|
|
120
|
+
message={row}
|
|
121
|
+
isUnread
|
|
122
|
+
onClick={() => alert("expand row-1")}
|
|
123
|
+
trailing={
|
|
124
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={(e) => {
|
|
128
|
+
e.stopPropagation();
|
|
129
|
+
alert("toggle star");
|
|
130
|
+
}}
|
|
131
|
+
aria-label="Add star"
|
|
132
|
+
className="rounded p-0.5 text-fg-subtle hover:text-warning"
|
|
133
|
+
>
|
|
134
|
+
<Star className="size-3" />
|
|
135
|
+
</button>
|
|
136
|
+
<span className="text-2xs text-fg-subtle tabular-nums">
|
|
137
|
+
Today, 09:11
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
}
|
|
141
|
+
/>
|
|
142
|
+
<CollapsedMessage
|
|
143
|
+
message={{ ...row, id: "row-2", fromName: "Alex Rivera" }}
|
|
144
|
+
isFocused
|
|
145
|
+
onClick={() => alert("expand row-2")}
|
|
146
|
+
trailing={
|
|
147
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={(e) => {
|
|
151
|
+
e.stopPropagation();
|
|
152
|
+
alert("toggle star");
|
|
153
|
+
}}
|
|
154
|
+
aria-label="Remove star"
|
|
155
|
+
className="rounded p-0.5 text-warning"
|
|
156
|
+
>
|
|
157
|
+
<Star className="size-3 fill-current" />
|
|
158
|
+
</button>
|
|
159
|
+
<Paperclip className="size-3 text-fg-subtle" />
|
|
160
|
+
<span className="text-2xs text-fg-subtle tabular-nums">
|
|
161
|
+
Today, 08:42
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
164
|
+
}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** The expanded row with the app's injected slots: a sender badge, an
|
|
171
|
+
* indicators row, an action-menu placeholder and a custom recipient line —
|
|
172
|
+
* proving the kit composes app interactivity without importing app code. */
|
|
173
|
+
export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
|
|
174
|
+
render: () => (
|
|
175
|
+
<div className="max-w-3xl border border-line">
|
|
176
|
+
<ExpandedMessage
|
|
177
|
+
message={row}
|
|
178
|
+
senderBadge={<span className="ml-1 text-positive text-xs">✓</span>}
|
|
179
|
+
to={<>to Alex Rivera and 2 others</>}
|
|
180
|
+
indicators={
|
|
181
|
+
<div className="mt-0.5 flex items-center gap-1">
|
|
182
|
+
<Star className="size-3.5 fill-current text-warning" />
|
|
183
|
+
<Paperclip className="size-3.5 text-fg-subtle" />
|
|
184
|
+
</div>
|
|
185
|
+
}
|
|
186
|
+
actionMenu={
|
|
187
|
+
<button type="button" className="rounded p-1 text-fg-subtle">
|
|
188
|
+
⋯
|
|
189
|
+
</button>
|
|
190
|
+
}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
),
|
|
194
|
+
};
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChevronDown,
|
|
3
|
+
ChevronRight,
|
|
4
|
+
Info,
|
|
5
|
+
Search,
|
|
6
|
+
ShieldAlert,
|
|
7
|
+
SquarePen,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { type ReactNode, useState } from "react";
|
|
10
|
+
import { cn } from "../lib/cn.js";
|
|
11
|
+
import { isSelfRowActivation } from "../lib/row-keyboard.js";
|
|
12
|
+
import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
13
|
+
import { Avatar } from "./avatar.js";
|
|
14
|
+
import { Button } from "./button.js";
|
|
15
|
+
import { Input } from "./input.js";
|
|
16
|
+
import { MailActionToolbar } from "./mail-action-toolbar.js";
|
|
17
|
+
import { MessageBodyView } from "./message-body-view.js";
|
|
18
|
+
import { ReadingPaneEmpty } from "./reading-pane-empty.js";
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* Pane 3: threaded reading pane */
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One collapsed thread row. Presentational and prop-driven: the kit owns the
|
|
26
|
+
* row rhythm (shared `px-2`/`lg:px-4` gutter, leading chevron, `md` avatar,
|
|
27
|
+
* fixed-width sender, snippet) and the app injects its interactivity via slots
|
|
28
|
+
* — `onClick` to expand, `isFocused`/`isUnread` for the keyboard ring and
|
|
29
|
+
* unread dot, and a `trailing` slot for the star/attachment/date cluster. With
|
|
30
|
+
* no slots it renders the fixture date label so the kit stories stand alone.
|
|
31
|
+
*
|
|
32
|
+
* The row is a `role="button"` div rather than a native `<button>` so the
|
|
33
|
+
* `trailing` slot can hold its own interactive controls (the star toggle) —
|
|
34
|
+
* `<button>` inside `<button>` is invalid HTML and throws a hydration error
|
|
35
|
+
* (#1232). Keyboard operation (Enter/Space) is wired by hand to keep the whole
|
|
36
|
+
* row expandable, guarded so a keypress bubbling up from the star doesn't also
|
|
37
|
+
* expand the row (`isSelfRowActivation`); the star stops click propagation so it
|
|
38
|
+
* toggles on its own.
|
|
39
|
+
*/
|
|
40
|
+
export function CollapsedMessage({
|
|
41
|
+
message,
|
|
42
|
+
onClick,
|
|
43
|
+
isFocused,
|
|
44
|
+
isUnread,
|
|
45
|
+
trailing,
|
|
46
|
+
}: {
|
|
47
|
+
message: ThreadMessageData;
|
|
48
|
+
/** Expand handler. Omit for a static (story / SSR) row. */
|
|
49
|
+
onClick?: () => void;
|
|
50
|
+
/** Keyboard-focus indicator (inset ring), independent of hover. */
|
|
51
|
+
isFocused?: boolean;
|
|
52
|
+
/** Bolds the sender and overlays the unread dot on the avatar. */
|
|
53
|
+
isUnread?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Trailing cluster (star button, attachment icon, date). Replaces the kit's
|
|
56
|
+
* default fixture date label so the app can inject its real interactive
|
|
57
|
+
* controls without the kit importing app/data code.
|
|
58
|
+
*/
|
|
59
|
+
trailing?: ReactNode;
|
|
60
|
+
}) {
|
|
61
|
+
const content = (
|
|
62
|
+
<>
|
|
63
|
+
<ChevronRight className="size-3.5 shrink-0 text-fg-subtle" />
|
|
64
|
+
<div className="relative shrink-0">
|
|
65
|
+
<Avatar name={message.fromName} email={message.fromEmail} size="md" />
|
|
66
|
+
{isUnread && (
|
|
67
|
+
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full border border-canvas bg-accent" />
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
<span
|
|
71
|
+
className={cn(
|
|
72
|
+
"w-36 shrink-0 truncate text-sm",
|
|
73
|
+
isUnread ? "font-semibold text-fg" : "font-medium text-fg-muted",
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{message.fromName}
|
|
77
|
+
</span>
|
|
78
|
+
<span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
|
|
79
|
+
{message.snippet}
|
|
80
|
+
</span>
|
|
81
|
+
{trailing ?? (
|
|
82
|
+
<span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
|
|
83
|
+
{message.dateLabel}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const className = cn(
|
|
90
|
+
"group flex h-section-row w-full items-center gap-3 border-b border-line px-2 text-left transition-colors hover:bg-surface-sunken lg:px-4",
|
|
91
|
+
isFocused && "bg-surface-sunken ring-1 ring-inset ring-accent/30",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!onClick) {
|
|
95
|
+
return <div className={className}>{content}</div>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
// biome-ignore lint/a11y/useSemanticElements: a nested <button> (the star in the trailing slot) inside a native <button> is invalid HTML and throws a hydration error (#1232); a role="button" div keeps the whole row keyboard-expandable while letting the star be its own control
|
|
100
|
+
<div
|
|
101
|
+
role="button"
|
|
102
|
+
tabIndex={0}
|
|
103
|
+
aria-expanded={false}
|
|
104
|
+
onClick={onClick}
|
|
105
|
+
onKeyDown={(e) => {
|
|
106
|
+
if (!isSelfRowActivation(e)) return;
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
onClick();
|
|
109
|
+
}}
|
|
110
|
+
className={cn(className, "cursor-pointer")}
|
|
111
|
+
>
|
|
112
|
+
{content}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One expanded thread message. Presentational and prop-driven: the kit owns the
|
|
119
|
+
* header rhythm (leading chevron, `md` avatar, sender block) and the body
|
|
120
|
+
* spacing; the app injects its interactivity and real data via slots —
|
|
121
|
+
* `onHeaderClick` to collapse, `isFocused`/`isUnread`, a `senderBadge` after the
|
|
122
|
+
* name, `trailing` for the date, `to` for the recipient line, `indicators` for
|
|
123
|
+
* the star/attachment row, `actionMenu` for the kebab, and `body` for the real
|
|
124
|
+
* message body (loading/error/raw). With no `body` it renders the fixture
|
|
125
|
+
* `MessageBodyView` so the kit stories stand alone.
|
|
126
|
+
*/
|
|
127
|
+
export function ExpandedMessage({
|
|
128
|
+
message,
|
|
129
|
+
warning,
|
|
130
|
+
onHeaderClick,
|
|
131
|
+
isFocused,
|
|
132
|
+
senderBadge,
|
|
133
|
+
trailing,
|
|
134
|
+
to,
|
|
135
|
+
indicators,
|
|
136
|
+
actionMenu,
|
|
137
|
+
actionBar,
|
|
138
|
+
body,
|
|
139
|
+
}: {
|
|
140
|
+
message: ThreadMessageData;
|
|
141
|
+
warning?: string;
|
|
142
|
+
/** Collapse handler on the sender block. Omit for a static row. */
|
|
143
|
+
onHeaderClick?: () => void;
|
|
144
|
+
isFocused?: boolean;
|
|
145
|
+
/** Rendered after the sender name (e.g. a trusted-sender badge). */
|
|
146
|
+
senderBadge?: ReactNode;
|
|
147
|
+
/** Trailing header content (date). Defaults to the fixture date label. */
|
|
148
|
+
trailing?: ReactNode;
|
|
149
|
+
/** Recipient line. `undefined` → "to {message.toLabel}"; `null` → hidden. */
|
|
150
|
+
to?: ReactNode;
|
|
151
|
+
/** Row under the header (star / attachment / unread dot). */
|
|
152
|
+
indicators?: ReactNode;
|
|
153
|
+
/** Per-message action menu (kebab), right-aligned in the header. */
|
|
154
|
+
actionMenu?: ReactNode;
|
|
155
|
+
/**
|
|
156
|
+
* Per-message action bar (reply / triage), rendered between the header and the
|
|
157
|
+
* body. The narrow-width reading view puts the message's verbs here so they
|
|
158
|
+
* belong to this message; collapsed rows pass none.
|
|
159
|
+
*/
|
|
160
|
+
actionBar?: ReactNode;
|
|
161
|
+
/**
|
|
162
|
+
* The message body. Replaces the kit's fixture `MessageBodyView` so the app
|
|
163
|
+
* can inject its real data-driven body (loading / error / raw toggle).
|
|
164
|
+
*/
|
|
165
|
+
body?: ReactNode;
|
|
166
|
+
}) {
|
|
167
|
+
const sender = (
|
|
168
|
+
<>
|
|
169
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
170
|
+
<span className="text-sm font-semibold text-fg">
|
|
171
|
+
{message.fromName}
|
|
172
|
+
{senderBadge}
|
|
173
|
+
</span>
|
|
174
|
+
{trailing ?? (
|
|
175
|
+
<span className="text-2xs text-fg-subtle">{message.dateLabel}</span>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
<div className="text-xs text-fg-subtle">{message.fromEmail}</div>
|
|
179
|
+
{to !== null && (
|
|
180
|
+
<div className="text-xs text-fg-subtle">
|
|
181
|
+
{to ?? <>to {message.toLabel}</>}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
className={cn(
|
|
190
|
+
"px-2 py-3 lg:px-4",
|
|
191
|
+
isFocused && "ring-1 ring-inset ring-accent/30",
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
<div className="flex items-start gap-3">
|
|
195
|
+
<ChevronDown className="mt-1 size-3.5 shrink-0 text-fg-subtle" />
|
|
196
|
+
<Avatar name={message.fromName} email={message.fromEmail} size="md" />
|
|
197
|
+
<div className="min-w-0 flex-1">
|
|
198
|
+
{onHeaderClick ? (
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={onHeaderClick}
|
|
202
|
+
className="w-full text-left"
|
|
203
|
+
>
|
|
204
|
+
{sender}
|
|
205
|
+
</button>
|
|
206
|
+
) : (
|
|
207
|
+
sender
|
|
208
|
+
)}
|
|
209
|
+
{indicators}
|
|
210
|
+
</div>
|
|
211
|
+
{actionMenu && <div className="shrink-0">{actionMenu}</div>}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{warning && (
|
|
215
|
+
<div className="mt-3 flex items-start gap-3 rounded-md bg-danger-soft p-3">
|
|
216
|
+
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-danger" />
|
|
217
|
+
<div className="text-sm text-fg">
|
|
218
|
+
<span className="font-semibold text-danger">Caution: </span>
|
|
219
|
+
{warning}{" "}
|
|
220
|
+
<button type="button" className="text-accent underline">
|
|
221
|
+
Why?
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{actionBar && <div className="mt-3 -mx-2 lg:-mx-4">{actionBar}</div>}
|
|
228
|
+
|
|
229
|
+
{/* The body is injected by the app (real sanitize → classify →
|
|
230
|
+
sandboxed-iframe pipeline via its MessageBody). With no `body`
|
|
231
|
+
slot the kit renders MessageBodyView directly so Storybook shows
|
|
232
|
+
exactly what the app renders, not a divergent inline-HTML mock
|
|
233
|
+
(#940). `framed` fixtures map to the newsletter treatment (author
|
|
234
|
+
colors preserved); the rest render plain. */}
|
|
235
|
+
{body ?? (
|
|
236
|
+
<MessageBodyView
|
|
237
|
+
className="mt-3"
|
|
238
|
+
html={message.bodyHtml}
|
|
239
|
+
category={message.framed ? "newsletter" : "personal"}
|
|
240
|
+
allowImages
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Message action toolbar on the pane-header datum: the reading pane's
|
|
249
|
+
* verbs (reply/reply-all/forward, delete/move/flag) plus search and
|
|
250
|
+
* compose, Apple Mail-style above the message area. Built on the shared
|
|
251
|
+
* `MailActionToolbar` so the buttons stay pressable with no thread open and
|
|
252
|
+
* explain inline rather than greying out (the never-disable tenet).
|
|
253
|
+
*/
|
|
254
|
+
function MessageToolbar({
|
|
255
|
+
hasThread,
|
|
256
|
+
intelligenceOpen,
|
|
257
|
+
onToggleIntelligence,
|
|
258
|
+
showIntelligenceToggle,
|
|
259
|
+
}: {
|
|
260
|
+
hasThread: boolean;
|
|
261
|
+
intelligenceOpen?: boolean;
|
|
262
|
+
onToggleIntelligence?: () => void;
|
|
263
|
+
showIntelligenceToggle?: boolean;
|
|
264
|
+
}) {
|
|
265
|
+
const [hint, setHint] = useState<string | null>(null);
|
|
266
|
+
return (
|
|
267
|
+
<MailActionToolbar
|
|
268
|
+
hasThread={hasThread}
|
|
269
|
+
onUnavailable={() => setHint("Open a message first")}
|
|
270
|
+
unavailableHint={hint}
|
|
271
|
+
>
|
|
272
|
+
{/* Apple Mail geometry: search sits top-right over the message
|
|
273
|
+
area but still filters the current list / brief */}
|
|
274
|
+
<Input
|
|
275
|
+
icon={<Search className="size-4" />}
|
|
276
|
+
placeholder="Search mail"
|
|
277
|
+
className="h-8 w-64 min-w-40 shrink"
|
|
278
|
+
/>
|
|
279
|
+
<span className="mx-1 h-4 w-px bg-line" aria-hidden />
|
|
280
|
+
<Button
|
|
281
|
+
variant="ghost"
|
|
282
|
+
size="sm"
|
|
283
|
+
icon={<SquarePen className="size-4" />}
|
|
284
|
+
title="Compose (⌘N)"
|
|
285
|
+
aria-label="Compose"
|
|
286
|
+
/>
|
|
287
|
+
{showIntelligenceToggle && !intelligenceOpen && (
|
|
288
|
+
<Button
|
|
289
|
+
variant="ghost"
|
|
290
|
+
size="sm"
|
|
291
|
+
icon={<Info className="size-4" />}
|
|
292
|
+
onClick={onToggleIntelligence}
|
|
293
|
+
aria-label="Show intelligence sidebar"
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
</MailActionToolbar>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function ReadingPane({
|
|
301
|
+
thread,
|
|
302
|
+
intelligenceOpen,
|
|
303
|
+
onToggleIntelligence,
|
|
304
|
+
showIntelligenceToggle,
|
|
305
|
+
}: {
|
|
306
|
+
thread?: ThreadData;
|
|
307
|
+
intelligenceOpen?: boolean;
|
|
308
|
+
onToggleIntelligence?: () => void;
|
|
309
|
+
showIntelligenceToggle?: boolean;
|
|
310
|
+
}) {
|
|
311
|
+
return (
|
|
312
|
+
<article className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
313
|
+
<MessageToolbar
|
|
314
|
+
hasThread={Boolean(thread)}
|
|
315
|
+
intelligenceOpen={intelligenceOpen}
|
|
316
|
+
onToggleIntelligence={onToggleIntelligence}
|
|
317
|
+
showIntelligenceToggle={showIntelligenceToggle}
|
|
318
|
+
/>
|
|
319
|
+
|
|
320
|
+
{!thread ? (
|
|
321
|
+
<ReadingPaneEmpty />
|
|
322
|
+
) : (
|
|
323
|
+
<div className="flex-1 overflow-y-auto">
|
|
324
|
+
{/* subject lives with the content, Apple Mail-style: once per
|
|
325
|
+
thread, scrolls with the message */}
|
|
326
|
+
<div className="border-b border-line px-5 pt-5 pb-3">
|
|
327
|
+
<h2 className="max-w-2xl text-lg font-semibold leading-snug text-fg">
|
|
328
|
+
{thread.subject}
|
|
329
|
+
</h2>
|
|
330
|
+
<p className="mt-1 text-2xs text-fg-subtle">
|
|
331
|
+
{thread.messages.length}{" "}
|
|
332
|
+
{thread.messages.length === 1 ? "message" : "messages"}
|
|
333
|
+
</p>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{thread.messages.map((message) =>
|
|
337
|
+
message.expanded ? (
|
|
338
|
+
<ExpandedMessage
|
|
339
|
+
key={message.id}
|
|
340
|
+
message={message}
|
|
341
|
+
warning={thread.warning}
|
|
342
|
+
/>
|
|
343
|
+
) : (
|
|
344
|
+
<CollapsedMessage key={message.id} message={message} />
|
|
345
|
+
),
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
</article>
|
|
350
|
+
);
|
|
351
|
+
}
|