@remit/ui 0.0.113 → 0.0.115

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 (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { useState } from "react";
2
+ import { useMemo, useState } from "react";
3
3
  import { expect, userEvent, waitFor } from "storybook/test";
4
4
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
5
5
  import { ComposeLanguageChip } from "./compose-language-chip.js";
@@ -295,6 +295,12 @@ const staleSpellcheck: SpellcheckOptions = {
295
295
  ),
296
296
  });
297
297
  },
298
+ suggest: (request) =>
299
+ Promise.resolve({
300
+ requestId: request.requestId,
301
+ word: request.word,
302
+ suggestions: [],
303
+ }),
298
304
  close: () => {},
299
305
  }),
300
306
  };
@@ -357,10 +363,8 @@ export const SpellcheckMarks: Story = {
357
363
  );
358
364
 
359
365
  // The word the caret sits in is left alone until the writer moves on.
360
- await userEvent.click(editable);
361
- const line = editable.querySelector<HTMLElement>("[data-lexical-text]");
362
- const characters = line?.firstChild ?? null;
363
- canvasElement.ownerDocument.getSelection()?.setPosition(characters, 17);
366
+ editable.focus();
367
+ caretTo(editable, 17);
364
368
 
365
369
  await waitFor(
366
370
  () =>
@@ -406,6 +410,504 @@ export const SpellcheckStaleAnswer: Story = {
406
410
  },
407
411
  };
408
412
 
413
+ const MISSPELT_MESSAGE = [
414
+ "Ths is the agenda for tomorow.",
415
+ "I attachd the budgt figures and the meetign notes,",
416
+ "and will confrm the schedual seperately.",
417
+ ].join(" ");
418
+
419
+ /**
420
+ * The worker again, with the suggestions held back long enough to see the menu
421
+ * open without them. A slow engine is the normal case, not the exception: the
422
+ * menu is up on the click and the rows arrive after it.
423
+ */
424
+ const slowSuggestions = (
425
+ delayMs: number,
426
+ onAddWord?: (word: string) => void,
427
+ ): SpellcheckOptions => ({
428
+ onAddWord,
429
+ provider: async (language) => {
430
+ const provider = await openSpellcheckWorker(language);
431
+ if (!provider) return null;
432
+ return {
433
+ ...provider,
434
+ suggest: (request) =>
435
+ new Promise((resolve) => {
436
+ setTimeout(() => resolve(provider.suggest(request)), delayMs);
437
+ }),
438
+ };
439
+ },
440
+ });
441
+
442
+ const markRect = (editable: HTMLElement, word: string): DOMRect => {
443
+ for (const range of spellMarks(editable)) {
444
+ const text = range.startContainer.textContent ?? "";
445
+ if (text.slice(range.startOffset, range.endOffset) !== word) continue;
446
+ return (range as Range).getBoundingClientRect();
447
+ }
448
+ throw new Error(`"${word}" carries no mark`);
449
+ };
450
+
451
+ const centreOf = (box: DOMRect) => ({
452
+ clientX: box.left + box.width / 2,
453
+ clientY: box.top + box.height / 2,
454
+ });
455
+
456
+ interface Press {
457
+ readonly pointerType?: string;
458
+ readonly travel?: number;
459
+ readonly button?: number;
460
+ }
461
+
462
+ const pressDownOn = (
463
+ editable: HTMLElement,
464
+ word: string,
465
+ { pointerType = "mouse", button = 0 }: Press = {},
466
+ ): void => {
467
+ editable.dispatchEvent(
468
+ new PointerEvent("pointerdown", {
469
+ bubbles: true,
470
+ pointerType,
471
+ button,
472
+ ...centreOf(markRect(editable, word)),
473
+ }),
474
+ );
475
+ };
476
+
477
+ const pressUpOn = (
478
+ editable: HTMLElement,
479
+ word: string,
480
+ { pointerType = "mouse", travel = 0, button = 0 }: Press = {},
481
+ ): void => {
482
+ const at = centreOf(markRect(editable, word));
483
+ editable.dispatchEvent(
484
+ new PointerEvent("pointerup", {
485
+ bubbles: true,
486
+ pointerType,
487
+ button,
488
+ ...at,
489
+ clientX: at.clientX + travel,
490
+ }),
491
+ );
492
+ };
493
+
494
+ const pressOn = (editable: HTMLElement, word: string, press: Press = {}) => {
495
+ pressDownOn(editable, word, press);
496
+ pressUpOn(editable, word, press);
497
+ };
498
+
499
+ const tapOn = (editable: HTMLElement, word: string): void =>
500
+ pressOn(editable, word, { pointerType: "touch" });
501
+
502
+ const clickOn = (editable: HTMLElement, word: string): void =>
503
+ pressOn(editable, word);
504
+
505
+ const caretTo = (editable: HTMLElement, offset: number): void => {
506
+ const line = editable.querySelector<HTMLElement>("[data-lexical-text]");
507
+ editable.ownerDocument
508
+ .getSelection()
509
+ ?.setPosition(line?.firstChild ?? null, offset);
510
+ };
511
+
512
+ const textOf = (editable: HTMLElement): string =>
513
+ editable.querySelector<HTMLElement>("[data-lexical-text]")?.textContent ?? "";
514
+
515
+ /**
516
+ * The caret the browser would have put down under the press. Storybook's events
517
+ * are dispatched rather than trusted, so none of them move the caret on their
518
+ * own.
519
+ */
520
+ const caretInto = (editable: HTMLElement, word: string): void =>
521
+ caretTo(editable, textOf(editable).indexOf(word) + 1);
522
+
523
+ const caretToEnd = (editable: HTMLElement): void =>
524
+ caretTo(editable, textOf(editable).length);
525
+
526
+ /**
527
+ * The desktop popover portals to the document body, clear of whatever
528
+ * ancestor would otherwise clip it, so the menu and its rows are found
529
+ * against the iframe document rather than `canvasElement` — the sheet below
530
+ * the desktop gate stays inside `canvasElement`, and this still reaches it
531
+ * there too.
532
+ */
533
+ const menuRows = (
534
+ canvasElement: HTMLElement,
535
+ testId: string,
536
+ ): HTMLElement[] => [
537
+ ...canvasElement.ownerDocument.body.querySelectorAll<HTMLElement>(
538
+ `[data-testid=${testId}]`,
539
+ ),
540
+ ];
541
+
542
+ const spellNode = (
543
+ canvasElement: HTMLElement,
544
+ selector: string,
545
+ ): Element | null => canvasElement.ownerDocument.body.querySelector(selector);
546
+
547
+ /**
548
+ * The correction menu over a marked word: up on the click with rows standing in
549
+ * for suggestions that have not arrived, filled when they do, and one undo away
550
+ * from the word that was there before.
551
+ */
552
+ export const SpellcheckSuggestions: Story = {
553
+ name: "Spellcheck suggestions",
554
+ args: {
555
+ initialHtml: `<p>${MISSPELT}</p>`,
556
+ lang: "en",
557
+ spellcheck: slowSuggestions(600),
558
+ },
559
+ play: async ({ canvasElement }) => {
560
+ const editable = writingSurface(canvasElement);
561
+ await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
562
+ timeout: 5000,
563
+ });
564
+
565
+ clickOn(editable, "Ths");
566
+ await waitFor(() =>
567
+ expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeTruthy(),
568
+ );
569
+ await expect(
570
+ menuRows(canvasElement, "spell-suggestion-skeleton"),
571
+ ).toHaveLength(3);
572
+
573
+ await waitFor(
574
+ () =>
575
+ expect(
576
+ menuRows(canvasElement, "spell-suggestion").map(
577
+ (row) => row.textContent,
578
+ ),
579
+ ).toEqual(["The", "This", "Than", "That", "Them"]),
580
+ { timeout: 5000 },
581
+ );
582
+
583
+ const [first] = menuRows(canvasElement, "spell-suggestion");
584
+ if (!first) throw new Error("no suggestion to pick");
585
+ await userEvent.click(first);
586
+
587
+ await expect(editable.textContent).toBe(
588
+ MISSPELT.replace("Ths report", "The report"),
589
+ );
590
+ await waitFor(() => expect(spellMarks(editable).length).toBe(2), {
591
+ timeout: 5000,
592
+ });
593
+
594
+ editable.focus();
595
+ await userEvent.keyboard("{Control>}z{/Control}");
596
+ await waitFor(() => expect(editable.textContent).toBe(MISSPELT), {
597
+ timeout: 5000,
598
+ });
599
+ },
600
+ };
601
+
602
+ /**
603
+ * A misspelt word at the right edge of a narrow, `overflow-hidden` editor —
604
+ * the compose body's own shape (#731). The menu portals clear of that
605
+ * ancestor rather than being clipped by it, and it stays inside the viewport
606
+ * rather than running past the container's right edge and taking the page's
607
+ * own scrollbar with it.
608
+ */
609
+ export const SpellcheckNearRightEdge: Story = {
610
+ name: "Spellcheck menu at the right edge of a narrow editor",
611
+ args: {
612
+ initialHtml: `<p>${MISSPELT_MESSAGE}</p>`,
613
+ lang: "en",
614
+ spellcheck: workerSpellcheck,
615
+ },
616
+ decorators: [
617
+ (Story) => (
618
+ <div
619
+ data-testid="body-area"
620
+ className="flex h-[260px] w-[320px] flex-col overflow-hidden rounded-md border border-line bg-canvas"
621
+ >
622
+ <Story />
623
+ </div>
624
+ ),
625
+ ],
626
+ play: async ({ canvasElement }) => {
627
+ const editable = writingSurface(canvasElement);
628
+ const frame = canvasElement.querySelector<HTMLElement>(
629
+ "[data-testid=body-area]",
630
+ );
631
+ if (!frame) throw new Error("the narrow frame is not mounted");
632
+
633
+ await waitFor(
634
+ () => expect(spellMarks(editable).length).toBeGreaterThan(0),
635
+ {
636
+ timeout: 5000,
637
+ },
638
+ );
639
+
640
+ clickOn(editable, "confrm");
641
+ const menu = await waitFor(() => {
642
+ const found = spellNode(canvasElement, "[data-testid=spell-menu]");
643
+ if (!found) throw new Error("the menu has not opened yet");
644
+ return found;
645
+ });
646
+
647
+ // Portalled clear of the clipping ancestor rather than cut by it.
648
+ await expect(frame.contains(menu)).toBe(false);
649
+
650
+ const box = menu.getBoundingClientRect();
651
+ await expect(box.left).toBeGreaterThanOrEqual(0);
652
+ await expect(box.right).toBeLessThanOrEqual(window.innerWidth);
653
+
654
+ await expect(
655
+ spellNode(canvasElement, "[data-testid=spell-ignore]")?.textContent,
656
+ ).toBe("Ignore for now");
657
+ },
658
+ };
659
+
660
+ /**
661
+ * Ignoring a word takes its marks off for as long as the composer is open, and
662
+ * later passes over the same text leave it alone. Nothing is written down: the
663
+ * next composer marks it again.
664
+ */
665
+ export const SpellcheckSessionIgnore: Story = {
666
+ name: "Spellcheck ignores a word for the session",
667
+ args: {
668
+ initialHtml: `<p>${MISSPELT}</p>`,
669
+ lang: "en",
670
+ spellcheck: workerSpellcheck,
671
+ },
672
+ play: async ({ canvasElement }) => {
673
+ const editable = writingSurface(canvasElement);
674
+ await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
675
+ timeout: 5000,
676
+ });
677
+
678
+ clickOn(editable, "attachd");
679
+ const [ignore] = await waitFor(() => {
680
+ const rows = menuRows(canvasElement, "spell-ignore");
681
+ expect(rows).toHaveLength(1);
682
+ return rows;
683
+ });
684
+ if (!ignore) throw new Error("the menu offers no way to ignore");
685
+ await userEvent.click(ignore);
686
+
687
+ await waitFor(() => expect(spellMarks(editable).length).toBe(2));
688
+
689
+ // Typed onto the end rather than clicked into: a click on this surface is
690
+ // now how the corrections open, and it would take the keyboard with it.
691
+ editable.focus();
692
+ caretToEnd(editable);
693
+ await userEvent.keyboard(" Attachd again.");
694
+ await waitFor(
695
+ () =>
696
+ expect(
697
+ spellMarks(editable).filter((range) => {
698
+ const text = range.startContainer.textContent ?? "";
699
+ return (
700
+ text.slice(range.startOffset, range.endOffset).toLowerCase() ===
701
+ "attachd"
702
+ );
703
+ }),
704
+ ).toHaveLength(0),
705
+ { timeout: 5000 },
706
+ );
707
+ },
708
+ };
709
+
710
+ /**
711
+ * A plain left click is what opens the corrections: a tablet has no right button
712
+ * and nothing should be hidden behind one. The caret the click puts down is
713
+ * reading rather than writing, so the squiggle it landed on stays where it is —
714
+ * clicking a marked word must not be what unmarks it. The next key is the writer
715
+ * back at the text, and the word under the caret goes quiet again.
716
+ */
717
+ export const SpellcheckClickOpensCorrections: Story = {
718
+ name: "Spellcheck opens on a click",
719
+ args: {
720
+ initialHtml: `<p>${MISSPELT}</p>`,
721
+ lang: "en",
722
+ spellcheck: workerSpellcheck,
723
+ },
724
+ play: async ({ canvasElement }) => {
725
+ const editable = writingSurface(canvasElement);
726
+ const marked: [number, number][] = [
727
+ [0, 3],
728
+ [14, 18],
729
+ [44, 51],
730
+ ];
731
+ await waitFor(() => expect(spellMarkOffsets(editable)).toEqual(marked), {
732
+ timeout: 5000,
733
+ });
734
+
735
+ editable.focus();
736
+ pressDownOn(editable, "redy");
737
+ caretInto(editable, "redy");
738
+ pressUpOn(editable, "redy");
739
+
740
+ await waitFor(() =>
741
+ expect(
742
+ spellNode(canvasElement, "[data-testid=spell-word]")?.textContent,
743
+ ).toBe("redy"),
744
+ );
745
+ // Long enough for the caret the click put down to have been read and
746
+ // another checking pass to have gone by. It is still not what unmarks the
747
+ // word: this is the writer looking at the corrections for it.
748
+ await new Promise((rested) => setTimeout(rested, 600));
749
+ await expect(spellMarkOffsets(editable)).toEqual(marked);
750
+
751
+ await userEvent.keyboard("{Escape}");
752
+ await waitFor(() =>
753
+ expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeNull(),
754
+ );
755
+
756
+ caretInto(editable, "redy");
757
+ editable.dispatchEvent(
758
+ new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
759
+ );
760
+ await waitFor(
761
+ () =>
762
+ expect(spellMarkOffsets(editable)).toEqual([
763
+ [0, 3],
764
+ [44, 51],
765
+ ]),
766
+ { timeout: 5000 },
767
+ );
768
+ },
769
+ };
770
+
771
+ /**
772
+ * The same rows below the desktop gate: a tap in a marked word raises the sheet
773
+ * rather than a popover under the thumb that opened it.
774
+ */
775
+ export const SpellcheckOnTouch: Story = {
776
+ name: "Spellcheck corrections on a phone",
777
+ globals: { viewport: { value: "mobile" } },
778
+ args: {
779
+ initialHtml: `<p>${MISSPELT}</p>`,
780
+ lang: "en",
781
+ spellcheck: workerSpellcheck,
782
+ },
783
+ decorators: [
784
+ (Story) => (
785
+ <div
786
+ data-testid="body-area"
787
+ className="relative flex h-[560px] w-[360px] flex-col overflow-hidden rounded-md border border-line bg-canvas"
788
+ >
789
+ <Story />
790
+ </div>
791
+ ),
792
+ ],
793
+ play: async ({ canvasElement }) => {
794
+ const editable = writingSurface(canvasElement);
795
+ await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
796
+ timeout: 5000,
797
+ });
798
+
799
+ tapOn(editable, "redy");
800
+ await waitFor(() =>
801
+ expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeTruthy(),
802
+ );
803
+ await expect(
804
+ spellNode(canvasElement, "[aria-label='Close corrections']"),
805
+ ).toBeTruthy();
806
+ },
807
+ };
808
+
809
+ /**
810
+ * The other things a pointer does over a marked word: dragging out a selection
811
+ * with either a finger or a mouse, and the right button, which belongs to the
812
+ * browser's own menu. A sheet over the top of any of them is the one thing they
813
+ * must not get. Closing the sheet hands the caret back.
814
+ */
815
+ export const SpellcheckLeavesAPointerAlone: Story = {
816
+ ...SpellcheckOnTouch,
817
+ name: "Spellcheck leaves a drag and the right button alone",
818
+ play: async ({ canvasElement }) => {
819
+ const editable = writingSurface(canvasElement);
820
+ await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
821
+ timeout: 5000,
822
+ });
823
+ const menu = () => spellNode(canvasElement, "[data-testid=spell-menu]");
824
+
825
+ pressOn(editable, "redy", { pointerType: "touch", travel: 40 });
826
+ await expect(menu()).toBeNull();
827
+
828
+ pressOn(editable, "redy", { travel: 40 });
829
+ await expect(menu()).toBeNull();
830
+
831
+ pressOn(editable, "redy", { button: 2 });
832
+ await expect(menu()).toBeNull();
833
+
834
+ const contextMenu = new MouseEvent("contextmenu", {
835
+ bubbles: true,
836
+ cancelable: true,
837
+ ...centreOf(markRect(editable, "redy")),
838
+ });
839
+ editable.dispatchEvent(contextMenu);
840
+ await expect(contextMenu.defaultPrevented).toBe(false);
841
+ await expect(menu()).toBeNull();
842
+
843
+ tapOn(editable, "redy");
844
+ await waitFor(() => expect(menu()).toBeTruthy());
845
+
846
+ await userEvent.click(
847
+ spellNode(
848
+ canvasElement,
849
+ "[aria-label='Close corrections']",
850
+ ) as HTMLElement,
851
+ );
852
+ await waitFor(() => expect(menu()).toBeNull());
853
+ await waitFor(() => expect(document.activeElement).toBe(editable));
854
+ },
855
+ };
856
+
857
+ /**
858
+ * The whole experience, hands on: type into a message full of misspellings,
859
+ * click a squiggle (or put the caret in one and press Ctrl+.), pick a
860
+ * correction, ignore a word, or add it to the list this mount keeps. The
861
+ * theme toolbar switches both surfaces.
862
+ */
863
+ const SpellcheckWorkbench = () => {
864
+ const [added, setAdded] = useState<string[]>([]);
865
+ const spellcheck = useMemo(
866
+ () => slowSuggestions(450, (word) => setAdded((words) => [...words, word])),
867
+ [],
868
+ );
869
+
870
+ return (
871
+ <>
872
+ <RichTextEditor
873
+ initialHtml={`<p>${MISSPELT_MESSAGE}</p><p>Anything you type here is checked the same way.</p>`}
874
+ lang="en"
875
+ spellcheck={spellcheck}
876
+ trailing={pinnedControls}
877
+ />
878
+ <p
879
+ data-testid="spell-added-words"
880
+ className="shrink-0 border-t border-line px-3 py-2 text-xs text-fg-subtle"
881
+ >
882
+ {added.length === 0
883
+ ? "Added to the dictionary: nothing yet."
884
+ : `Added to the dictionary: ${added.join(", ")}.`}
885
+ </p>
886
+ </>
887
+ );
888
+ };
889
+
890
+ export const SpellcheckPlayground: Story = {
891
+ name: "Spellcheck playground",
892
+ render: () => <SpellcheckWorkbench />,
893
+ };
894
+
895
+ export const SpellcheckPlaygroundOnTouch: Story = {
896
+ name: "Spellcheck playground on a phone",
897
+ globals: { viewport: { value: "mobile" } },
898
+ render: () => <SpellcheckWorkbench />,
899
+ decorators: [
900
+ (Story) => (
901
+ <div
902
+ data-testid="body-area"
903
+ className="relative flex h-[640px] w-[360px] flex-col overflow-hidden rounded-md border border-line bg-canvas"
904
+ >
905
+ <Story />
906
+ </div>
907
+ ),
908
+ ],
909
+ };
910
+
409
911
  /**
410
912
  * The toolbar and the body share one scroller, so twenty lines of typing would
411
913
  * carry the toolbar off the top with them. It stays at the top of the body