@remit/ui 0.0.55 → 0.0.57

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/app-shell-types.ts +8 -1
  3. package/src/components/auto-moved-badge.stories.tsx +3 -21
  4. package/src/components/auto-moved-badge.tsx +8 -17
  5. package/src/components/brief-empty.stories.tsx +51 -0
  6. package/src/components/brief-empty.tsx +60 -0
  7. package/src/components/dialog.tsx +10 -5
  8. package/src/components/filter-clause-chip.tsx +39 -1
  9. package/src/components/filter-rule-editor.stories.tsx +163 -1
  10. package/src/components/filter-rule-editor.tsx +47 -0
  11. package/src/components/filter-rule.render.test.ts +33 -0
  12. package/src/components/filter-rule.ts +141 -0
  13. package/src/components/input.tsx +7 -0
  14. package/src/components/mail-header.tsx +10 -0
  15. package/src/components/message-row.tsx +41 -3
  16. package/src/components/mobile-search-view.render.test.ts +31 -0
  17. package/src/components/mobile-search-view.stories.tsx +115 -6
  18. package/src/components/mobile-search-view.tsx +34 -8
  19. package/src/components/password-input.render.test.ts +149 -0
  20. package/src/components/password-input.tsx +45 -0
  21. package/src/components/primitives.stories.tsx +28 -0
  22. package/src/components/search-bar.tsx +9 -0
  23. package/src/components/search-chip-input.tsx +85 -3
  24. package/src/components/search-result-row.tsx +68 -82
  25. package/src/components/search-results.stories.tsx +12 -0
  26. package/src/components/search-results.tsx +20 -8
  27. package/src/components/suggest-list.render.test.ts +59 -0
  28. package/src/components/suggest-list.tsx +96 -0
  29. package/src/components/swipeable-row.gesture.test.ts +128 -2
  30. package/src/components/swipeable-row.stories.tsx +54 -0
  31. package/src/components/swipeable-row.tsx +60 -31
  32. package/src/index.ts +37 -0
  33. package/src/lib/suggest-keys.test.ts +101 -0
  34. package/src/lib/suggest-keys.ts +63 -0
  35. package/src/lib/use-long-press.ts +70 -37
  36. package/src/lib/use-suggest-list.test.ts +169 -0
  37. package/src/lib/use-suggest-list.ts +124 -0
@@ -27,14 +27,18 @@ import {
27
27
  type LabelOption,
28
28
  type MatchOperator,
29
29
  matchJoinWord,
30
+ matchModeHint,
31
+ matchModeLabel,
30
32
  matchOperatorLabel,
31
33
  type PreviewCount,
34
+ type RuleMatchMode,
32
35
  type RuleScope,
33
36
  } from "./filter-rule.js";
34
37
  import { Input } from "./input.js";
35
38
  import { LabelChip } from "./label-chip.js";
36
39
  import { SegmentedControl } from "./segmented-control.js";
37
40
  import { Select } from "./select.js";
41
+ import type { Suggestion } from "./suggest-list.js";
38
42
 
39
43
  export interface ClauseEditState {
40
44
  mode: "add" | "edit";
@@ -61,6 +65,14 @@ export interface FilterRuleEditorProps {
61
65
  * widen still renders, marked inactive.
62
66
  */
63
67
  semanticAvailable?: boolean;
68
+ /**
69
+ * What the rule matches on, when the surface lets the user choose (RFC 038
70
+ * D2/D3). Present only where both modes are reachable — the Organize flow on
71
+ * a deployment that can serve the widen. Absent leaves the mode control off
72
+ * entirely, which is every other entry point onto this editor.
73
+ */
74
+ matchMode?: RuleMatchMode;
75
+ onChangeMatchMode?: (mode: RuleMatchMode) => void;
64
76
  /**
65
77
  * The clause fields the add/edit picker offers, in menu order. Defaults to the
66
78
  * whole vocabulary; a consumer narrows it to the fields its deployment can
@@ -82,6 +94,13 @@ export interface FilterRuleEditorProps {
82
94
  * filter, which is dropped from the scope toggle.
83
95
  */
84
96
  anchorLocked?: boolean;
97
+ /**
98
+ * Values worth offering for the clause being edited — the consumer derives
99
+ * them from the field and what has been typed so far (known senders for
100
+ * `From`, their domains for `FromDomain`). Absent leaves the value a plain
101
+ * text box, which is what a surface with nothing to suggest should be.
102
+ */
103
+ clauseSuggestions?: readonly Suggestion[];
85
104
  /** The inline clause form, when adding or editing a clause. */
86
105
  clauseEdit?: ClauseEditState;
87
106
  onStartAddClause?: () => void;
@@ -125,6 +144,11 @@ const matchOptions: { value: MatchOperator; label: string }[] = [
125
144
  { value: "any", label: matchOperatorLabel("any") },
126
145
  ];
127
146
 
147
+ const matchModeOptions: { value: RuleMatchMode; label: string }[] = [
148
+ { value: "similar", label: matchModeLabel("similar") },
149
+ { value: "properties", label: matchModeLabel("properties") },
150
+ ];
151
+
128
152
  const scopeOptions: { value: RuleScope; label: string }[] = [
129
153
  { value: "once", label: "Just once" },
130
154
  { value: "standing", label: "Keep doing this" },
@@ -450,8 +474,11 @@ export function FilterRuleEditor({
450
474
  preview,
451
475
  notice,
452
476
  semanticAvailable = false,
477
+ matchMode,
478
+ onChangeMatchMode,
453
479
  clauseFields,
454
480
  anchorLocked = false,
481
+ clauseSuggestions,
455
482
  clauseEdit,
456
483
  onStartAddClause,
457
484
  onStartEditClause,
@@ -497,6 +524,24 @@ export function FilterRuleEditor({
497
524
 
498
525
  <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
499
526
  {notice}
527
+
528
+ {matchMode && (
529
+ <section className="space-y-1.5">
530
+ <p className="text-xs font-medium text-fg-muted">Match on</p>
531
+ <SegmentedControl
532
+ name="rule-match-mode"
533
+ size="sm"
534
+ aria-label="What the rule matches on"
535
+ options={matchModeOptions}
536
+ value={matchMode}
537
+ onChange={(value) => onChangeMatchMode?.(value)}
538
+ />
539
+ <p className="text-2xs text-fg-subtle">
540
+ {matchModeHint(matchMode)}
541
+ </p>
542
+ </section>
543
+ )}
544
+
500
545
  <section className="space-y-3">
501
546
  <div className="flex flex-wrap items-center gap-1.5">
502
547
  {rule.clauses.map((clause, index) => (
@@ -534,6 +579,7 @@ export function FilterRuleEditor({
534
579
  {!rule.widen &&
535
580
  !anchorLocked &&
536
581
  semanticAvailable &&
582
+ matchMode !== "properties" &&
537
583
  !clauseEdit && (
538
584
  <AddChipButton label="…and similar" onClick={onAddWiden} />
539
585
  )}
@@ -544,6 +590,7 @@ export function FilterRuleEditor({
544
590
  draft={clauseEdit.draft}
545
591
  mode={clauseEdit.mode}
546
592
  fields={clauseFields}
593
+ suggestions={clauseSuggestions}
547
594
  onChangeField={onChangeDraftField}
548
595
  onChangeValue={onChangeDraftValue}
549
596
  onSubmit={onSubmitClause}
@@ -369,6 +369,39 @@ describe("ClauseEditor", () => {
369
369
  assert.doesNotMatch(html, /registrable domain/i);
370
370
  assert.doesNotMatch(html, /List-Id/);
371
371
  });
372
+
373
+ it("is a plain text box when there is nothing to suggest", () => {
374
+ const html = render(
375
+ createElement(ClauseEditor, {
376
+ draft: { field: "From", value: "" },
377
+ mode: "add",
378
+ }),
379
+ );
380
+ assert.doesNotMatch(html, /role="listbox"/);
381
+ assert.match(html, /aria-expanded="false"/);
382
+ });
383
+
384
+ it("offers the suggestions under the row, named for the field being edited", () => {
385
+ const html = render(
386
+ createElement(ClauseEditor, {
387
+ draft: { field: "From", value: "" },
388
+ mode: "add",
389
+ suggestions: [
390
+ {
391
+ value: "receipts@stripe.com",
392
+ label: "Stripe",
393
+ hint: "receipts@stripe.com",
394
+ source: "selected",
395
+ },
396
+ { value: "rides@lyft.com" },
397
+ ],
398
+ }),
399
+ );
400
+ assert.match(html, /role="listbox"/);
401
+ assert.match(html, /aria-label="From suggestions"/);
402
+ assert.match(html, /aria-expanded="true"/);
403
+ assert.match(html, /receipts@stripe\.com/);
404
+ });
372
405
  });
373
406
 
374
407
  describe("clauseFieldHint", () => {
@@ -7,6 +7,8 @@
7
7
  * Naming tracks the RFC: rule, clause, widen, scope.
8
8
  */
9
9
 
10
+ import type { Suggestion } from "./suggest-list.js";
11
+
10
12
  /**
11
13
  * The clause fields (RFC 038 D2). `From`, `Subject`, `HasWords` ship now;
12
14
  * `ListId` and `FromDomain` arrive with the vocabulary ticket. Every variant
@@ -34,6 +36,35 @@ export interface RuleClause {
34
36
  /** How the clauses combine. Maps to the API's `And` / `Or`. */
35
37
  export type MatchOperator = "all" | "any";
36
38
 
39
+ /**
40
+ * What the rule matches on (RFC 038 D2/D3). `similar` rides the semantic widen
41
+ * off the messages the rule was started from; `properties` uses literal clauses
42
+ * only — sender, subject, list — and needs no vector pipeline at all. A surface
43
+ * that offers both opens on `similar` wherever the deployment can serve it.
44
+ */
45
+ export type RuleMatchMode = "similar" | "properties";
46
+
47
+ const matchModeLabels: Record<RuleMatchMode, string> = {
48
+ similar: "Anything similar",
49
+ properties: "Its properties",
50
+ };
51
+
52
+ export function matchModeLabel(mode: RuleMatchMode): string {
53
+ return matchModeLabels[mode];
54
+ }
55
+
56
+ const matchModeHints: Record<RuleMatchMode, string> = {
57
+ similar: "Reads the messages you picked and finds more that read like them.",
58
+ properties:
59
+ "Matches on what the messages say about themselves — sender, subject, list. No reading involved.",
60
+ };
61
+
62
+ /** One line saying what a match mode actually does, so the choice is never a
63
+ * guess (ux.md). */
64
+ export function matchModeHint(mode: RuleMatchMode): string {
65
+ return matchModeHints[mode];
66
+ }
67
+
37
68
  /**
38
69
  * When the rule stops (RFC 038 D1). `once` is a one-time action, `standing`
39
70
  * persists, `until` expires on a picked date.
@@ -124,6 +155,36 @@ export function clauseFieldHint(field: ClauseField): string | undefined {
124
155
  return clauseFieldHints[field];
125
156
  }
126
157
 
158
+ /**
159
+ * Whether a clause field matches on message body text, which only the live
160
+ * index-time filter and the semantic widen can read.
161
+ *
162
+ * The vector-free matcher serves `From`/`Subject` from the core thread rows and
163
+ * carries no faithful body, so it rejects a body-text clause outright rather
164
+ * than narrowing the match silently (`assertNoBodyContentClause`,
165
+ * backend/service/organize.ts). A rule with no active widen therefore cannot be
166
+ * counted or applied one-time with such a clause in it — it can only be a
167
+ * standing rule, where the index-time matcher reads the whole body.
168
+ */
169
+ export function matchesBodyText(field: ClauseField): boolean {
170
+ return field === "HasWords";
171
+ }
172
+
173
+ /** Whether the rule's semantic widen is present and evaluable. */
174
+ export function hasActiveWiden(rule: FilterRule): boolean {
175
+ return rule.widen !== undefined && !rule.widen.inactive;
176
+ }
177
+
178
+ /**
179
+ * The rule's body-text clauses that nothing on its current match path can read
180
+ * — a `HasWords` chip with no active widen behind it. Empty for every rule the
181
+ * literal matcher can evaluate.
182
+ */
183
+ export function unreadableBodyClauses(rule: FilterRule): RuleClause[] {
184
+ if (hasActiveWiden(rule)) return [];
185
+ return rule.clauses.filter((clause) => matchesBodyText(clause.field));
186
+ }
187
+
127
188
  /** The fields a new clause can be added as, in menu order. */
128
189
  export const clauseFieldOrder: ClauseField[] = [
129
190
  "From",
@@ -198,6 +259,11 @@ export function commitBlockedReason(
198
259
  rule.clauses.length > 0 ||
199
260
  (rule.widen !== undefined && !rule.widen.inactive);
200
261
  if (!hasMatch) return "Add a clause so the rule has something to match.";
262
+ // A one-time apply runs the vector-free matcher, which cannot read message
263
+ // bodies. Saved as a rule the same clause works, so say that rather than
264
+ // refusing the clause outright.
265
+ if (rule.scope === "once" && unreadableBodyClauses(rule).length > 0)
266
+ return "Applying once can't read message bodies. Save this as a rule instead, or drop the “has the words” clause.";
201
267
  if (!rule.moveMailboxId && !rule.labelId)
202
268
  return "Pick a folder to move into, or a label to apply.";
203
269
  if (
@@ -252,6 +318,20 @@ export const demoVocabularyRule: FilterRule = {
252
318
  name: "Python lists",
253
319
  };
254
320
 
321
+ /**
322
+ * The properties mode's opening rule when the selection's senders differ: the
323
+ * part their subjects share, prefilled as an ordinary editable `Subject` chip.
324
+ */
325
+ export const demoSubjectPrefillRule: FilterRule = {
326
+ clauses: [
327
+ { id: "c1", field: "Subject", value: "Your receipt from", derived: true },
328
+ ],
329
+ matchOperator: "any",
330
+ moveMailboxId: "mbx-receipts",
331
+ scope: "once",
332
+ name: "",
333
+ };
334
+
255
335
  export const demoSenderFallbackRule: FilterRule = {
256
336
  clauses: [
257
337
  { id: "c1", field: "From", value: "receipts@stripe.com", derived: true },
@@ -262,3 +342,64 @@ export const demoSenderFallbackRule: FilterRule = {
262
342
  scope: "standing",
263
343
  name: "Receipts",
264
344
  };
345
+
346
+ /**
347
+ * Stand-in for what the app offers in a clause value field: the addresses of the
348
+ * messages that were selected, then addresses a lookup knows about. The app
349
+ * derives the same shape from its selection and the address search — the story
350
+ * only needs the shape, not the source.
351
+ */
352
+ const demoAddressPool: Suggestion[] = [
353
+ {
354
+ value: "receipts@stripe.com",
355
+ label: "Stripe",
356
+ hint: "receipts@stripe.com",
357
+ source: "selected",
358
+ },
359
+ {
360
+ value: "receipts@lyft.com",
361
+ label: "Lyft",
362
+ hint: "receipts@lyft.com",
363
+ source: "selected",
364
+ },
365
+ {
366
+ value: "no-reply@booking.com",
367
+ label: "Booking.com",
368
+ hint: "no-reply@booking.com",
369
+ },
370
+ {
371
+ value: "invoice@digitalocean.com",
372
+ label: "DigitalOcean",
373
+ hint: "invoice@digitalocean.com",
374
+ },
375
+ { value: "billing@github.com", label: "GitHub", hint: "billing@github.com" },
376
+ ];
377
+
378
+ const demoDomainPool: Suggestion[] = [
379
+ { value: "stripe.com", source: "selected" },
380
+ { value: "lyft.com", source: "selected" },
381
+ { value: "booking.com" },
382
+ { value: "digitalocean.com" },
383
+ { value: "github.com" },
384
+ ];
385
+
386
+ /**
387
+ * The suggestions a story's clause value field offers, matching the app's rule:
388
+ * address fields draw on known senders, every other field is free text, and what
389
+ * is typed narrows the list without ever constraining it.
390
+ */
391
+ export function demoClauseSuggestions(
392
+ field: ClauseField,
393
+ value: string,
394
+ ): Suggestion[] {
395
+ if (field !== "From" && field !== "FromDomain") return [];
396
+ const pool = field === "From" ? demoAddressPool : demoDomainPool;
397
+ const needle = value.trim().toLowerCase();
398
+ return pool.filter(
399
+ (suggestion) =>
400
+ suggestion.value.toLowerCase() !== needle &&
401
+ (needle === "" ||
402
+ suggestion.value.toLowerCase().includes(needle) ||
403
+ (suggestion.label ?? "").toLowerCase().includes(needle)),
404
+ );
405
+ }
@@ -12,6 +12,11 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
12
12
  * rows where the surrounding container owns the chrome.
13
13
  */
14
14
  variant?: InputVariant;
15
+ /**
16
+ * Optional trailing control rendered inside the field, after the input —
17
+ * it takes its own space in the row, so it never covers the typed value.
18
+ */
19
+ trailing?: ReactNode;
15
20
  /** Forwarded to the underlying `<input>`. */
16
21
  ref?: Ref<HTMLInputElement>;
17
22
  }
@@ -28,6 +33,7 @@ export function Input({
28
33
  icon,
29
34
  className,
30
35
  variant = "default",
36
+ trailing,
31
37
  ref,
32
38
  ...props
33
39
  }: InputProps) {
@@ -39,6 +45,7 @@ export function Input({
39
45
  className="min-w-0 flex-1 bg-transparent text-fg placeholder:text-fg-subtle outline-none"
40
46
  {...props}
41
47
  />
48
+ {trailing}
42
49
  </div>
43
50
  );
44
51
  }
@@ -1,6 +1,7 @@
1
1
  import { Menu, Search, X } from "lucide-react";
2
2
  import { Button } from "./button.js";
3
3
  import { SearchBar } from "./search-bar.js";
4
+ import type { SearchFieldSuggest } from "./search-chip-input.js";
4
5
 
5
6
  export interface MailHeaderProps {
6
7
  /** Top-row title — the view name, e.g. "Daily brief" or "Inbox". */
@@ -28,6 +29,13 @@ export interface MailHeaderProps {
28
29
  * the page never mounts two search inputs competing for the same focus.
29
30
  */
30
31
  showSearch?: boolean;
32
+ /**
33
+ * Completions for what is being typed; see `SearchChipInput`. The list is
34
+ * not rendered here — the header is a fixed-height row, so the consumer
35
+ * renders it in the pane directly below, where it takes its own space
36
+ * instead of covering the field.
37
+ */
38
+ searchSuggest?: SearchFieldSuggest;
31
39
  }
32
40
 
33
41
  /**
@@ -51,6 +59,7 @@ export function MailHeader({
51
59
  searchOpen,
52
60
  onSearchOpenChange,
53
61
  showSearch = true,
62
+ searchSuggest,
54
63
  }: MailHeaderProps) {
55
64
  const unreadLabel = `${unreadCount.toLocaleString()} unread`;
56
65
  const clearSearch = onSearchClear ?? (() => onSearchChange(""));
@@ -66,6 +75,7 @@ export function MailHeader({
66
75
  onClear={clearSearch}
67
76
  globalFocusKey={false}
68
77
  showClearButton={showClearButton}
78
+ suggest={searchSuggest}
69
79
  />
70
80
  );
71
81
 
@@ -101,6 +101,34 @@ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
101
101
  );
102
102
  }
103
103
 
104
+ /**
105
+ * Bold the literal (case-insensitive) occurrences of `query` in `text`. Used by
106
+ * the search rows so a match is visible in the subject and snippet it was found
107
+ * in; an absent or blank query leaves the text alone.
108
+ */
109
+ function highlightMatches(text: string, query?: string): ReactNode {
110
+ const term = query?.trim();
111
+ if (!term) return text;
112
+ const lower = text.toLowerCase();
113
+ const needle = term.toLowerCase();
114
+ const parts: ReactNode[] = [];
115
+ let cursor = 0;
116
+ let match = lower.indexOf(needle, cursor);
117
+ let key = 0;
118
+ while (match !== -1) {
119
+ if (match > cursor) parts.push(text.slice(cursor, match));
120
+ parts.push(
121
+ <mark key={key++} className="bg-transparent font-semibold text-fg">
122
+ {text.slice(match, match + needle.length)}
123
+ </mark>,
124
+ );
125
+ cursor = match + needle.length;
126
+ match = lower.indexOf(needle, cursor);
127
+ }
128
+ if (cursor < text.length) parts.push(text.slice(cursor));
129
+ return parts;
130
+ }
131
+
104
132
  /**
105
133
  * Text/glyph content block of a comfortable row (everything after the leading
106
134
  * avatar/slot). Consumers provide their own leading element (Avatar, checkbox,
@@ -110,10 +138,13 @@ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
110
138
  export function ComfortableRowTextContent({
111
139
  thread,
112
140
  badge,
141
+ highlightQuery,
113
142
  }: {
114
143
  thread: ThreadRowData;
115
144
  /** Extra chip rendered after the category badge (e.g. an auto-moved indicator). */
116
145
  badge?: ReactNode;
146
+ /** Bold this term where it occurs literally in the subject and snippet. */
147
+ highlightQuery?: string;
117
148
  }) {
118
149
  const unread = !thread.isRead;
119
150
  return (
@@ -146,7 +177,7 @@ export function ComfortableRowTextContent({
146
177
  unread ? "text-fg" : "text-fg-muted",
147
178
  )}
148
179
  >
149
- {thread.subject}
180
+ {highlightMatches(thread.subject, highlightQuery)}
150
181
  </span>
151
182
  {thread.suspicious && (
152
183
  <ShieldAlert className="size-3.5 shrink-0 text-danger" />
@@ -160,7 +191,7 @@ export function ComfortableRowTextContent({
160
191
  </span>
161
192
  <span className="flex items-center gap-1.5">
162
193
  <span className="line-clamp-1 min-w-0 flex-1 text-xs text-fg-subtle">
163
- {thread.snippet}
194
+ {highlightMatches(thread.snippet, highlightQuery)}
164
195
  </span>
165
196
  {thread.category && thread.category !== "personal" && (
166
197
  <Badge tone={categoryTone[thread.category]} className="shrink-0">
@@ -264,10 +295,13 @@ export function ComfortableRowBody({
264
295
  thread,
265
296
  selection,
266
297
  badge,
298
+ highlightQuery,
267
299
  }: {
268
300
  thread: ThreadRowData;
269
301
  selection?: RowSelection;
270
302
  badge?: ReactNode;
303
+ /** Bold this term where it occurs literally in the subject and snippet. */
304
+ highlightQuery?: string;
271
305
  }) {
272
306
  const unread = !thread.isRead;
273
307
  return (
@@ -276,7 +310,11 @@ export function ComfortableRowBody({
276
310
  <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
277
311
  )}
278
312
  <ComfortableRowLeading thread={thread} selection={selection} />
279
- <ComfortableRowTextContent thread={thread} badge={badge} />
313
+ <ComfortableRowTextContent
314
+ thread={thread}
315
+ badge={badge}
316
+ highlightQuery={highlightQuery}
317
+ />
280
318
  </>
281
319
  );
282
320
  }
@@ -37,6 +37,37 @@ const base = {
37
37
  sections,
38
38
  };
39
39
 
40
+ const filter = {
41
+ categories: [{ id: "all", label: "All" }],
42
+ filters: [{ id: "unread", label: "Unread" }],
43
+ selectedCategory: "all",
44
+ activeFilters: new Set<string>(),
45
+ onSelectCategory: noop,
46
+ onToggleFilter: noop,
47
+ onClear: noop,
48
+ };
49
+
50
+ describe("MobileSearchView filter chrome", () => {
51
+ it("gives the filter row up to the search once a query is typed", () => {
52
+ const html = renderToString(
53
+ createElement(MobileSearchView, { ...base, filter }),
54
+ );
55
+ assert.doesNotMatch(html, /Expand filters/);
56
+ });
57
+
58
+ it("keeps the filter row while the field is empty", () => {
59
+ const html = renderToString(
60
+ createElement(MobileSearchView, {
61
+ ...base,
62
+ value: "",
63
+ sections: [],
64
+ filter,
65
+ }),
66
+ );
67
+ assert.match(html, /Expand filters/);
68
+ });
69
+ });
70
+
40
71
  describe("MobileSearchView search scope", () => {
41
72
  it("offers held-out spam on the phone tier too", () => {
42
73
  const html = renderToString(