@remit/ui 0.0.113 → 0.0.114

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 (48) hide show
  1. package/package.json +1 -1
  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/message-list-pane.render.test.ts +2 -2
  36. package/src/components/message-list-pane.stories.tsx +1 -1
  37. package/src/components/nav-sidebar.tsx +20 -0
  38. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  39. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  40. package/src/components/recurrence-scope-prompt.tsx +84 -0
  41. package/src/components/selection-wizard.tsx +19 -69
  42. package/src/index.ts +111 -0
  43. package/src/lib/calendar-color.ts +71 -0
  44. package/src/lib/event-phrase.test.ts +89 -0
  45. package/src/lib/event-phrase.ts +228 -0
  46. package/src/lib/recurrence.test.ts +141 -0
  47. package/src/lib/recurrence.ts +266 -0
  48. package/src/tokens.css +63 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.113",
3
+ "version": "0.0.114",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -14,6 +14,10 @@ const render = (overrides: Partial<AppShellSlottedProps> = {}) =>
14
14
  }),
15
15
  );
16
16
 
17
+ /** The percentage react-resizable-panels gave the list panel. */
18
+ const listSize = (html: string) =>
19
+ html.match(/data-panel-id="list" data-panel-size="([\d.]+)"/)?.[1];
20
+
17
21
  describe("AppShellSlotted slot rendering", () => {
18
22
  it("renders the list slot unconditionally", () => {
19
23
  const html = render({ initialWidth: 800 });
@@ -80,6 +84,85 @@ describe("AppShellSlotted slot rendering", () => {
80
84
  );
81
85
  });
82
86
 
87
+ it("splits the list and reading panes 33/67 by default", () => {
88
+ assert.equal(
89
+ listSize(
90
+ render({
91
+ initialWidth: 1100,
92
+ reading: createElement(
93
+ "div",
94
+ { "data-testid": "reading" },
95
+ "Reading",
96
+ ),
97
+ }),
98
+ ),
99
+ "33.0",
100
+ "the list keeps its standard third",
101
+ );
102
+ });
103
+
104
+ it("widens the list default at compact density", () => {
105
+ assert.equal(
106
+ listSize(
107
+ render({
108
+ initialWidth: 1100,
109
+ density: "compact",
110
+ reading: createElement(
111
+ "div",
112
+ { "data-testid": "reading" },
113
+ "Reading",
114
+ ),
115
+ }),
116
+ ),
117
+ "43.0",
118
+ "denser rows read better with more room",
119
+ );
120
+ });
121
+
122
+ it("gives the list the larger share when the bias is on it", () => {
123
+ assert.equal(
124
+ listSize(
125
+ render({
126
+ initialWidth: 1100,
127
+ listBias: "list",
128
+ reading: createElement(
129
+ "div",
130
+ { "data-testid": "reading" },
131
+ "Reading",
132
+ ),
133
+ }),
134
+ ),
135
+ "62.0",
136
+ "a list that is the work keeps the width",
137
+ );
138
+ });
139
+
140
+ it("gives the list the smaller share when the bias is on the detail", () => {
141
+ assert.equal(
142
+ listSize(
143
+ render({
144
+ initialWidth: 1100,
145
+ listBias: "detail",
146
+ reading: createElement(
147
+ "div",
148
+ { "data-testid": "reading" },
149
+ "Reading",
150
+ ),
151
+ }),
152
+ ),
153
+ "26.0",
154
+ "a spine of a list gets a spine's width",
155
+ );
156
+ });
157
+
158
+ it("ignores the bias when there is no reading pane to split with", () => {
159
+ assert.equal(
160
+ listSize(render({ initialWidth: 1100, listBias: "list" })),
161
+ "100.0",
162
+ "one pane is the whole width at any bias",
163
+ );
164
+ });
165
+
83
166
  it("renders the nav as a pane at/above 1024px (no folders trigger)", () => {
84
167
  const html = render({ initialWidth: 1100 });
85
168
  assert.match(html, /data-testid="nav"/, "nav is in the pane");
@@ -54,6 +54,14 @@ export interface AppShellSlottedProps {
54
54
  * standard split. Only affects the two-pane (list + reading) default size.
55
55
  */
56
56
  density?: "comfortable" | "compact";
57
+ /**
58
+ * Which pane the two-pane split favours. "balanced" (default) is the mail
59
+ * arrangement, where the list is an index and the reading pane is the work.
60
+ * "list" is for a list pane that is itself the work — a calendar grid needs
61
+ * the width a week of columns costs, and cannot give it up when a detail
62
+ * opens. "detail" goes the other way for a list that is only a spine.
63
+ */
64
+ listBias?: "list" | "balanced" | "detail";
57
65
  /**
58
66
  * Header rendered only on narrow (< 1024px) widths — the mobile top bar.
59
67
  * The web-client injects the app-specific bar (hamburger / title / search).
@@ -123,6 +131,16 @@ export interface AppShellLayoutContext {
123
131
 
124
132
  const AppShellLayoutCtx = createContext<AppShellLayoutContext | null>(null);
125
133
 
134
+ /** The list panel's share of the two-pane group, and how far it may be dragged. */
135
+ function listShare(
136
+ bias: NonNullable<AppShellSlottedProps["listBias"]>,
137
+ density: NonNullable<AppShellSlottedProps["density"]>,
138
+ ): { size: number; max: number } {
139
+ if (bias === "list") return { size: 62, max: 78 };
140
+ if (bias === "detail") return { size: 26, max: 45 };
141
+ return { size: density === "compact" ? 43 : 33, max: 58 };
142
+ }
143
+
126
144
  /**
127
145
  * Read the enclosing `AppShellSlotted`'s pane layout.
128
146
  * Returns null outside of an `AppShellSlotted` (tests / Storybook).
@@ -143,6 +161,7 @@ export function AppShellSlotted({
143
161
  intelligenceOpen = true,
144
162
  hasThread = true,
145
163
  density = "comfortable",
164
+ listBias = "balanced",
146
165
  header,
147
166
  topBar,
148
167
  overlay,
@@ -200,14 +219,23 @@ export function AppShellSlotted({
200
219
 
201
220
  /* Sizes are percentages of the group they sit in. This group excludes the nav
202
221
  column, so they are shares of the remaining ~83%, not of the whole shell. */
222
+ const share = listShare(listBias, density);
203
223
  const contentPanes = (
204
- <ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
224
+ <ResizablePanelGroup
225
+ direction="horizontal"
226
+ className="min-h-0 flex-1"
227
+ /* A group whose panels come and go under it throws "previous layout not
228
+ found for panel index -1", and a panel already registered keeps the
229
+ layout it was given rather than the default it is handed next. Keying
230
+ on the set makes each arrangement its own group. */
231
+ key={showReadingPane ? "list-reading" : "list-only"}
232
+ >
205
233
  <ResizablePanel
206
234
  id="list"
207
235
  order={1}
208
- defaultSize={showReadingPane ? (density === "compact" ? 43 : 33) : 100}
236
+ defaultSize={showReadingPane ? share.size : 100}
209
237
  minSize={22}
210
- maxSize={showReadingPane ? 58 : 100}
238
+ maxSize={showReadingPane ? share.max : 100}
211
239
  className="min-w-0"
212
240
  >
213
241
  {list}
@@ -0,0 +1,73 @@
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 { AttendeeList, AttendeeRow, RsvpBadge } from "./attendee-row.js";
6
+ import type { CalendarAttendee } from "./calendar-types.js";
7
+
8
+ const attendees: CalendarAttendee[] = [
9
+ {
10
+ name: "Priya Natarajan",
11
+ email: "priya@northwind.example",
12
+ rsvp: "accepted",
13
+ role: "organizer",
14
+ },
15
+ {
16
+ name: "Dana Okafor",
17
+ email: "dana@northwind.example",
18
+ rsvp: "tentative",
19
+ role: "attendee",
20
+ },
21
+ {
22
+ name: "Sven Larsen",
23
+ email: "sven@northwind.example",
24
+ rsvp: "declined",
25
+ role: "attendee",
26
+ },
27
+ ];
28
+
29
+ describe("RsvpBadge", () => {
30
+ it("says the reply in words, not colour alone", () => {
31
+ assert.match(
32
+ renderToString(createElement(RsvpBadge, { rsvp: "noReply" })),
33
+ /No reply/,
34
+ );
35
+ assert.match(
36
+ renderToString(createElement(RsvpBadge, { rsvp: "declined" })),
37
+ /Not coming/,
38
+ );
39
+ });
40
+ });
41
+
42
+ describe("AttendeeRow", () => {
43
+ it("names the organiser", () => {
44
+ const html = renderToString(
45
+ createElement(AttendeeRow, { attendee: attendees[0] }),
46
+ );
47
+ assert.match(html, /Organiser/);
48
+ assert.match(html, /Priya Natarajan/);
49
+ });
50
+
51
+ it("leaves a plain attendee unlabelled", () => {
52
+ const html = renderToString(
53
+ createElement(AttendeeRow, { attendee: attendees[1] }),
54
+ );
55
+ assert.doesNotMatch(html, /Organiser/);
56
+ });
57
+ });
58
+
59
+ describe("AttendeeList", () => {
60
+ it("tallies the replies above the list", () => {
61
+ const html = renderToString(createElement(AttendeeList, { attendees }));
62
+ assert.match(html, /3 guests/);
63
+ assert.match(html, /1 coming/);
64
+ assert.match(html, /1 not coming/);
65
+ });
66
+
67
+ it("renders nothing when nobody is invited", () => {
68
+ assert.equal(
69
+ renderToString(createElement(AttendeeList, { attendees: [] })),
70
+ "",
71
+ );
72
+ });
73
+ });
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { AttendeeList, RsvpBadge } from "./attendee-row.js";
3
+ import type { CalendarAttendee } from "./calendar-types.js";
4
+
5
+ /**
6
+ * Who is coming, and whether they said so. The reply is words and a mark, never
7
+ * a colour on its own.
8
+ */
9
+ const meta: Meta = {
10
+ title: "Calendar/Attendees",
11
+ parameters: { layout: "padded" },
12
+ };
13
+ export default meta;
14
+
15
+ type Story = StoryObj;
16
+
17
+ const attendees: CalendarAttendee[] = [
18
+ {
19
+ name: "Priya Natarajan",
20
+ email: "priya@northwind.example",
21
+ rsvp: "accepted",
22
+ role: "organizer",
23
+ },
24
+ {
25
+ name: "Marcus Webb",
26
+ email: "marcus@northwind.example",
27
+ rsvp: "accepted",
28
+ role: "attendee",
29
+ },
30
+ {
31
+ name: "Dana Okafor",
32
+ email: "dana@northwind.example",
33
+ rsvp: "tentative",
34
+ role: "attendee",
35
+ },
36
+ {
37
+ name: "Aisha Khan",
38
+ email: "aisha@northwind.example",
39
+ rsvp: "noReply",
40
+ role: "attendee",
41
+ },
42
+ {
43
+ name: "Sven Larsen",
44
+ email: "sven@northwind.example",
45
+ rsvp: "declined",
46
+ role: "attendee",
47
+ },
48
+ ];
49
+
50
+ /** A full guest list, with the tally read off it rather than stated twice. */
51
+ export const List: Story = {
52
+ render: () => (
53
+ <div className="max-w-sm rounded-lg border border-line bg-surface p-3">
54
+ <AttendeeList attendees={attendees} />
55
+ </div>
56
+ ),
57
+ };
58
+
59
+ export const EveryReply: Story = {
60
+ render: () => (
61
+ <div className="flex gap-4">
62
+ <RsvpBadge rsvp="accepted" />
63
+ <RsvpBadge rsvp="tentative" />
64
+ <RsvpBadge rsvp="declined" />
65
+ <RsvpBadge rsvp="noReply" />
66
+ </div>
67
+ ),
68
+ };
@@ -0,0 +1,96 @@
1
+ import { Check, Clock, Minus, X } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { Avatar } from "./avatar.js";
4
+ import type { CalendarAttendee, RsvpState } from "./calendar-types.js";
5
+
6
+ const rsvpLabels: Record<RsvpState, string> = {
7
+ accepted: "Coming",
8
+ tentative: "Maybe",
9
+ declined: "Not coming",
10
+ noReply: "No reply",
11
+ };
12
+
13
+ const rsvpTone: Record<RsvpState, string> = {
14
+ accepted: "text-positive",
15
+ tentative: "text-warning",
16
+ declined: "text-danger",
17
+ noReply: "text-fg-subtle",
18
+ };
19
+
20
+ const rsvpIcons: Record<RsvpState, typeof Check> = {
21
+ accepted: Check,
22
+ tentative: Clock,
23
+ declined: X,
24
+ noReply: Minus,
25
+ };
26
+
27
+ export interface RsvpBadgeProps {
28
+ rsvp: RsvpState;
29
+ className?: string;
30
+ }
31
+
32
+ /** The reply, in words and a mark — never colour alone. */
33
+ export function RsvpBadge({ rsvp, className }: RsvpBadgeProps) {
34
+ const Icon = rsvpIcons[rsvp];
35
+ return (
36
+ <span
37
+ className={cn(
38
+ "inline-flex items-center gap-1 text-2xs font-medium",
39
+ rsvpTone[rsvp],
40
+ className,
41
+ )}
42
+ >
43
+ <Icon className="size-3" />
44
+ {rsvpLabels[rsvp]}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ export interface AttendeeRowProps {
50
+ attendee: CalendarAttendee;
51
+ className?: string;
52
+ }
53
+
54
+ export function AttendeeRow({ attendee, className }: AttendeeRowProps) {
55
+ return (
56
+ <div className={cn("flex min-h-9 items-center gap-2.5", className)}>
57
+ <Avatar name={attendee.name} email={attendee.email} size="sm" />
58
+ <span className="min-w-0 flex-1">
59
+ <span className="block truncate text-sm text-fg">{attendee.name}</span>
60
+ {attendee.role === "organizer" && (
61
+ <span className="block text-2xs text-fg-subtle">Organiser</span>
62
+ )}
63
+ </span>
64
+ <RsvpBadge rsvp={attendee.rsvp} />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export interface AttendeeListProps {
70
+ attendees: CalendarAttendee[];
71
+ className?: string;
72
+ }
73
+
74
+ /** The full guest list with a one-line tally above it. */
75
+ export function AttendeeList({ attendees, className }: AttendeeListProps) {
76
+ if (attendees.length === 0) return null;
77
+ const tally = (["accepted", "tentative", "declined", "noReply"] as const)
78
+ .map((state) => ({
79
+ state,
80
+ count: attendees.filter((a) => a.rsvp === state).length,
81
+ }))
82
+ .filter((entry) => entry.count > 0)
83
+ .map((entry) => `${entry.count} ${rsvpLabels[entry.state].toLowerCase()}`)
84
+ .join(" · ");
85
+
86
+ return (
87
+ <div className={cn("flex flex-col", className)}>
88
+ <p className="pb-1 text-2xs uppercase tracking-wider text-fg-subtle">
89
+ {`${attendees.length} guests · ${tally}`}
90
+ </p>
91
+ {attendees.map((attendee) => (
92
+ <AttendeeRow key={attendee.email} attendee={attendee} />
93
+ ))}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,67 @@
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 {
6
+ CalendarEventChip,
7
+ type CalendarEventChipProps,
8
+ } from "./calendar-event-chip.js";
9
+
10
+ const base: CalendarEventChipProps = {
11
+ title: "Q3 roadmap review",
12
+ timeText: "10:00",
13
+ color: "cal-1",
14
+ layout: "row",
15
+ density: "comfortable",
16
+ rsvp: "accepted",
17
+ status: "confirmed",
18
+ hasThread: false,
19
+ isRecurring: false,
20
+ zoneCertainty: "local",
21
+ selected: false,
22
+ };
23
+
24
+ const render = (props: Partial<CalendarEventChipProps>) =>
25
+ renderToString(createElement(CalendarEventChip, { ...base, ...props }));
26
+
27
+ describe("CalendarEventChip", () => {
28
+ it("carries the calendar's hue and nothing else coloured", () => {
29
+ const html = render({ color: "cal-3" });
30
+ assert.match(html, /bg-cal-3-soft/);
31
+ assert.match(html, /text-cal-3/);
32
+ });
33
+
34
+ it("renders the time ahead of the title", () => {
35
+ const html = render({});
36
+ assert.match(html, /10:00/);
37
+ assert.match(html, /Q3 roadmap review/);
38
+ });
39
+
40
+ it("omits the time for an all-day entry", () => {
41
+ const html = render({ timeText: "" });
42
+ assert.doesNotMatch(html, /tabular-nums/);
43
+ });
44
+
45
+ it("strikes a declined event rather than recolouring it", () => {
46
+ const html = render({ rsvp: "declined" });
47
+ assert.match(html, /line-through/);
48
+ assert.match(html, /bg-cal-1-soft/);
49
+ });
50
+
51
+ it("dashes the border of a tentative event", () => {
52
+ const html = render({ status: "tentative" });
53
+ assert.match(html, /border-dashed/);
54
+ });
55
+
56
+ it("marks an event that came from mail", () => {
57
+ assert.match(render({ hasThread: true }), /From mail/);
58
+ });
59
+
60
+ it("marks a repeating event", () => {
61
+ assert.match(render({ isRecurring: true }), /Repeats/);
62
+ });
63
+
64
+ it("flags a zone it could not determine", () => {
65
+ assert.match(render({ zoneCertainty: "ambiguous" }), /Unclear zone/);
66
+ });
67
+ });
@@ -0,0 +1,128 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { CalendarEventChip } from "./calendar-event-chip.js";
3
+ import { calendarColorIds } from "./calendar-types.js";
4
+
5
+ /**
6
+ * The event as it appears wherever the event's element is ours: an agenda, a
7
+ * picker, a day column we draw ourselves. Colour says which calendar; shape and
8
+ * mark say everything else, so an event never depends on hue alone to be read.
9
+ *
10
+ * Option A's grid is FullCalendar, which renders the element itself and accepts
11
+ * only a class string and the content inside it, so that one surface restates
12
+ * this shell rather than mounting the component. The two are held to the same
13
+ * values — hue, the dashed box for a provisional event, the dimming for a
14
+ * declined one, the mark size. What the grid cannot take from here is the
15
+ * element: no `aria-pressed`, no focus ring of ours, and a column too short for
16
+ * the second line this chip puts its marks on.
17
+ */
18
+ const meta: Meta<typeof CalendarEventChip> = {
19
+ title: "Calendar/Event chip",
20
+ component: CalendarEventChip,
21
+ parameters: { layout: "padded" },
22
+ };
23
+ export default meta;
24
+
25
+ type Story = StoryObj<typeof CalendarEventChip>;
26
+
27
+ const base = {
28
+ title: "Q3 roadmap review",
29
+ timeText: "10:00",
30
+ color: "cal-1",
31
+ layout: "row",
32
+ density: "comfortable",
33
+ rsvp: "accepted",
34
+ status: "confirmed",
35
+ hasThread: false,
36
+ isRecurring: false,
37
+ zoneCertainty: "local",
38
+ selected: false,
39
+ } as const;
40
+
41
+ /** The six calendar hues side by side, which is the only test that matters. */
42
+ export const EveryCalendarColour: Story = {
43
+ render: () => (
44
+ <div className="flex max-w-sm flex-col gap-1">
45
+ {calendarColorIds.map((color, index) => (
46
+ <CalendarEventChip
47
+ key={color}
48
+ {...base}
49
+ color={color}
50
+ title={`Calendar ${index + 1}`}
51
+ />
52
+ ))}
53
+ </div>
54
+ ),
55
+ };
56
+
57
+ /** Every state the chip carries, in the order it degrades. */
58
+ export const States: Story = {
59
+ render: () => (
60
+ <div className="flex max-w-sm flex-col gap-1">
61
+ <CalendarEventChip {...base} />
62
+ <CalendarEventChip {...base} title="Staff screen" status="tentative" />
63
+ <CalendarEventChip {...base} title="Vendor call" rsvp="declined" />
64
+ <CalendarEventChip
65
+ {...base}
66
+ title="Standup"
67
+ isRecurring
68
+ hasThread={false}
69
+ />
70
+ <CalendarEventChip {...base} title="Incident review" hasThread />
71
+ <CalendarEventChip
72
+ {...base}
73
+ title="Offsite dinner"
74
+ timeText="19:00"
75
+ zoneCertainty="ambiguous"
76
+ />
77
+ <CalendarEventChip {...base} title="Demo" selected />
78
+ </div>
79
+ ),
80
+ };
81
+
82
+ /** In a time grid the chip fills its slot; the rail keeps the hue readable. */
83
+ export const ColumnLayout: Story = {
84
+ render: () => (
85
+ <div className="flex h-40 gap-1">
86
+ <CalendarEventChip {...base} layout="column" color="cal-1" />
87
+ <CalendarEventChip
88
+ {...base}
89
+ layout="column"
90
+ color="cal-3"
91
+ title="Design crit"
92
+ timeText="10:15"
93
+ />
94
+ <CalendarEventChip
95
+ {...base}
96
+ layout="column"
97
+ color="cal-4"
98
+ title="Vendor call"
99
+ timeText="10:45"
100
+ rsvp="declined"
101
+ />
102
+ </div>
103
+ ),
104
+ };
105
+
106
+ /** Tighter density drops the chip to the smallest size that still reads. */
107
+ export const Compact: Story = {
108
+ render: () => (
109
+ <div className="flex max-w-sm flex-col gap-0.5">
110
+ <CalendarEventChip {...base} density="compact" />
111
+ <CalendarEventChip
112
+ {...base}
113
+ density="compact"
114
+ color="cal-2"
115
+ title="Lunch walk"
116
+ timeText="12:30"
117
+ hasThread
118
+ />
119
+ <CalendarEventChip
120
+ {...base}
121
+ density="compact"
122
+ color="cal-5"
123
+ title="Modular jam"
124
+ timeText="20:00"
125
+ />
126
+ </div>
127
+ ),
128
+ };