@remit/ui 0.0.155 → 0.0.157

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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Typing is the create path here, so the field is on screen at all times and
3
+ * the form is downstream of it.
4
+ *
5
+ * Every keystroke re-reads the sentence and writes the reading straight into
6
+ * the fields underneath, attributed to the words it came from. Where the
7
+ * sentence has two honest readings — which Friday, eight in the morning or
8
+ * eight at night — the reader asks instead of choosing, and the answer is one
9
+ * tap. Correcting the machine happens before the event exists.
10
+ */
11
+ import { AlertTriangle, Info, Wand2 } from "lucide-react";
12
+ import { formatShortDay } from "../lib/agenda-time.js";
13
+ import { cn } from "../lib/cn.js";
14
+ import type {
15
+ AgendaParse,
16
+ CalendarDescriptor,
17
+ ChoicePicks,
18
+ EventDraft,
19
+ } from "./calendar-types.js";
20
+ import { EventEditor } from "./event-editor.js";
21
+
22
+ export interface AgendaComposerProps {
23
+ phrase: string;
24
+ onPhraseChange: (phrase: string) => void;
25
+ parse: AgendaParse;
26
+ picks: ChoicePicks;
27
+ onPick: (choiceId: string, optionId: string) => void;
28
+ draft: EventDraft;
29
+ onDraftChange: (draft: EventDraft) => void;
30
+ calendars: CalendarDescriptor[];
31
+ expanded: boolean;
32
+ onToggleExpanded: () => void;
33
+ onSave: () => void;
34
+ onCancel: () => void;
35
+ saveLabel?: string;
36
+ /** The rule belongs to the series; an edit scoped to one instance reads it back. */
37
+ repeatEditable?: boolean;
38
+ /** Opens the custom-rule editor from the repeat picker. */
39
+ onCustomRepeat?: () => void;
40
+ /** The form is folded away until there is something to correct. */
41
+ open: boolean;
42
+ onOpen: () => void;
43
+ placeholder?: string;
44
+ touch?: boolean;
45
+ className?: string;
46
+ }
47
+
48
+ export interface AgendaPhraseFieldProps {
49
+ phrase: string;
50
+ onPhraseChange: (phrase: string) => void;
51
+ /** Focus or a keystroke unfolds the form under the field. */
52
+ onOpen: () => void;
53
+ onCommit: () => void;
54
+ placeholder?: string;
55
+ touch?: boolean;
56
+ className?: string;
57
+ }
58
+
59
+ export function AgendaPhraseField({
60
+ phrase,
61
+ onPhraseChange,
62
+ onOpen,
63
+ onCommit,
64
+ placeholder = "lunch with Jane friday 1pm",
65
+ touch,
66
+ className,
67
+ }: AgendaPhraseFieldProps) {
68
+ return (
69
+ <div
70
+ className={cn(
71
+ "flex items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30",
72
+ touch ? "min-h-11" : "h-9",
73
+ className,
74
+ )}
75
+ >
76
+ <Wand2 className="size-4 shrink-0 text-fg-subtle" aria-hidden />
77
+ <input
78
+ value={phrase}
79
+ aria-label="Describe the event"
80
+ placeholder={placeholder}
81
+ onFocus={onOpen}
82
+ onChange={(event) => {
83
+ onOpen();
84
+ onPhraseChange(event.target.value);
85
+ }}
86
+ onKeyDown={(event) => {
87
+ if (event.key === "Enter") onCommit();
88
+ }}
89
+ className="min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
90
+ />
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export function AgendaComposer({
96
+ phrase,
97
+ onPhraseChange,
98
+ parse,
99
+ picks,
100
+ onPick,
101
+ draft,
102
+ onDraftChange,
103
+ calendars,
104
+ expanded,
105
+ onToggleExpanded,
106
+ onSave,
107
+ onCancel,
108
+ saveLabel = "Add",
109
+ repeatEditable,
110
+ onCustomRepeat,
111
+ open,
112
+ onOpen,
113
+ placeholder = "lunch with Jane friday 1pm",
114
+ touch,
115
+ className,
116
+ }: AgendaComposerProps) {
117
+ return (
118
+ <div className={cn("flex flex-col gap-2", className)}>
119
+ <AgendaPhraseField
120
+ phrase={phrase}
121
+ onPhraseChange={onPhraseChange}
122
+ onOpen={onOpen}
123
+ onCommit={onSave}
124
+ placeholder={placeholder}
125
+ touch={touch}
126
+ />
127
+
128
+ {open && (
129
+ <EventEditor
130
+ draft={draft}
131
+ onChange={onDraftChange}
132
+ calendars={calendars}
133
+ expanded={expanded}
134
+ onToggleExpanded={onToggleExpanded}
135
+ onSave={onSave}
136
+ onCancel={onCancel}
137
+ saveLabel={saveLabel}
138
+ repeatEditable={repeatEditable}
139
+ onCustomRepeat={onCustomRepeat}
140
+ touch={touch}
141
+ header={
142
+ phrase.trim() === "" ? undefined : (
143
+ <PhraseReading
144
+ parse={parse}
145
+ picks={picks}
146
+ onPick={onPick}
147
+ touch={touch}
148
+ />
149
+ )
150
+ }
151
+ />
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ export function PhraseReading({
158
+ parse,
159
+ picks,
160
+ onPick,
161
+ touch,
162
+ }: {
163
+ parse: AgendaParse;
164
+ picks: ChoicePicks;
165
+ onPick: (choiceId: string, optionId: string) => void;
166
+ touch?: boolean;
167
+ }) {
168
+ const when =
169
+ parse.startTime === ""
170
+ ? formatShortDay(parse.date)
171
+ : `${formatShortDay(parse.date)} ${parse.startTime} – ${parse.endTime}`;
172
+
173
+ return (
174
+ <div className="flex flex-col gap-1 rounded-md border border-line bg-surface px-2.5 py-2">
175
+ <Reading
176
+ label="What"
177
+ value={parse.title === "" ? "—" : parse.title}
178
+ source=""
179
+ />
180
+ <Reading
181
+ label="When"
182
+ value={when}
183
+ source={[parse.dateText, parse.startTimeText]
184
+ .filter((part) => part !== "")
185
+ .join(" ")}
186
+ />
187
+ {parse.repeat !== "" && (
188
+ <Reading
189
+ label="Repeat"
190
+ value={parse.repeat}
191
+ source={parse.repeatText}
192
+ />
193
+ )}
194
+ {parse.attendees.length > 0 && (
195
+ <Reading
196
+ label="Who"
197
+ value={parse.attendees.join(", ")}
198
+ source={parse.attendeesText}
199
+ />
200
+ )}
201
+ {parse.location !== "" && (
202
+ <Reading
203
+ label="Where"
204
+ value={parse.location}
205
+ source={parse.locationText}
206
+ />
207
+ )}
208
+
209
+ {parse.choices.map((choice) => (
210
+ <div
211
+ key={choice.id}
212
+ className="mt-1 flex flex-wrap items-center gap-1.5 rounded-md border border-warning/50 bg-warning-soft/30 px-2 py-1.5"
213
+ >
214
+ <AlertTriangle className="size-3 shrink-0 text-warning" aria-hidden />
215
+ <span className="text-2xs text-warning">{choice.question}</span>
216
+ {choice.options.map((option) => {
217
+ const chosen = (picks[choice.id] ?? choice.chosenId) === option.id;
218
+ return (
219
+ <button
220
+ key={option.id}
221
+ type="button"
222
+ aria-pressed={chosen}
223
+ onClick={() => onPick(choice.id, option.id)}
224
+ className={cn(
225
+ "rounded-full border px-2 text-2xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
226
+ touch ? "min-h-9" : "h-6",
227
+ chosen
228
+ ? "border-warning bg-warning-soft font-medium text-warning"
229
+ : "border-line text-fg-muted hover:border-line-strong hover:text-fg",
230
+ )}
231
+ >
232
+ {option.label}
233
+ </button>
234
+ );
235
+ })}
236
+ </div>
237
+ ))}
238
+
239
+ {parse.assumptions.map((note) => (
240
+ <p
241
+ key={note}
242
+ className="flex items-center gap-1.5 text-2xs text-fg-subtle"
243
+ >
244
+ <Info className="size-3 shrink-0" aria-hidden />
245
+ {note}
246
+ </p>
247
+ ))}
248
+ {parse.unresolved.map((note) => (
249
+ <p
250
+ key={note}
251
+ className="flex items-center gap-1.5 text-2xs text-warning"
252
+ >
253
+ <AlertTriangle className="size-3 shrink-0" aria-hidden />
254
+ {note}
255
+ </p>
256
+ ))}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function Reading({
262
+ label,
263
+ value,
264
+ source,
265
+ }: {
266
+ label: string;
267
+ value: string;
268
+ source: string;
269
+ }) {
270
+ return (
271
+ <div className="flex min-w-0 items-baseline gap-1.5">
272
+ <span className="w-12 shrink-0 text-2xs uppercase tracking-wider text-fg-subtle">
273
+ {label}
274
+ </span>
275
+ <span className="truncate text-xs font-medium text-fg">{value}</span>
276
+ {source !== "" && (
277
+ <span className="shrink-0 rounded-xs bg-accent-2-soft px-1 text-2xs text-accent-2">
278
+ {source}
279
+ </span>
280
+ )}
281
+ </div>
282
+ );
283
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The rules the strip makes on screen: what a day costs in rows, where free
3
+ * time is drawn, which days collapse into a sentence, and what a pile-up says
4
+ * about itself. Every one of them is a claim the design argues for, so it is
5
+ * asserted off the rendered markup rather than off the arithmetic underneath.
6
+ */
7
+ import "@remit/test-dom";
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { createElement } from "react";
11
+ import { renderToString } from "react-dom/server";
12
+ import { buildCalendarDay } from "../lib/agenda-time.js";
13
+ import { AgendaFlow, type AgendaFlowProps } from "./agenda-flow.js";
14
+ import type {
15
+ CalendarDescriptor,
16
+ CalendarEventData,
17
+ } from "./calendar-types.js";
18
+
19
+ const TODAY = "2026-06-10";
20
+ const OFFSET = "+02:00";
21
+
22
+ const calendars: CalendarDescriptor[] = [
23
+ {
24
+ id: "c1",
25
+ accountId: "a1",
26
+ accountLabel: "Work",
27
+ name: "Northwind",
28
+ color: "cal-3",
29
+ },
30
+ ];
31
+
32
+ function event(
33
+ id: string,
34
+ date: string,
35
+ from: string,
36
+ to: string,
37
+ extra: Partial<CalendarEventData> = {},
38
+ ): CalendarEventData {
39
+ return {
40
+ id,
41
+ calendarId: "c1",
42
+ title: id,
43
+ start: `${date}T${from}:00${OFFSET}`,
44
+ end: `${date}T${to}:00${OFFSET}`,
45
+ allDay: false,
46
+ location: "",
47
+ notes: "",
48
+ attendees: [],
49
+ myRsvp: "accepted",
50
+ threadId: "",
51
+ threadSubject: "",
52
+ timeZone: "Europe/Amsterdam",
53
+ zoneCertainty: "explicit",
54
+ recurrenceRule: "",
55
+ seriesId: "",
56
+ seriesException: false,
57
+ status: "confirmed",
58
+ ...extra,
59
+ };
60
+ }
61
+
62
+ const roadmap = event("evt_roadmap", TODAY, "10:00", "11:30", {
63
+ title: "Q3 roadmap review",
64
+ location: "Kaap",
65
+ attendees: [
66
+ {
67
+ name: "Anna",
68
+ email: "anna@example.test",
69
+ rsvp: "accepted",
70
+ role: "organizer",
71
+ },
72
+ ],
73
+ });
74
+ const incident = event("evt_incident", TODAY, "10:30", "12:00", {
75
+ title: "Incident review",
76
+ });
77
+ const banner: CalendarEventData = {
78
+ ...event("evt_conference", "2026-06-11", "00:00", "00:00"),
79
+ title: "Devcon",
80
+ start: "2026-06-11",
81
+ end: "2026-06-12",
82
+ allDay: true,
83
+ };
84
+
85
+ const events = [roadmap, incident, banner];
86
+
87
+ const dates = [
88
+ TODAY,
89
+ "2026-06-11",
90
+ "2026-06-12",
91
+ "2026-06-13",
92
+ "2026-06-14",
93
+ "2026-06-15",
94
+ "2026-06-16",
95
+ ];
96
+
97
+ const days = dates.map((date) => buildCalendarDay(date, events, TODAY));
98
+
99
+ const base: AgendaFlowProps = {
100
+ days,
101
+ calendars,
102
+ density: "pills",
103
+ today: TODAY,
104
+ focusDate: TODAY,
105
+ selectedEventId: "",
106
+ onSelectEvent: () => {},
107
+ onPickSlot: () => {},
108
+ onZoomDay: () => {},
109
+ onReachStart: () => {},
110
+ onReachEnd: () => {},
111
+ onVisibleDayChange: () => {},
112
+ };
113
+
114
+ /** React writes a marker between adjacent text nodes; a reader sees one sentence. */
115
+ const words = (html: string) => html.replaceAll("<!-- -->", "");
116
+
117
+ const render = (props: Partial<AgendaFlowProps> = {}) =>
118
+ renderToString(createElement(AgendaFlow, { ...base, ...props }));
119
+
120
+ describe("AgendaFlow", () => {
121
+ it("draws the days in the order it was handed them", () => {
122
+ const html = render();
123
+ assert.ok(
124
+ html.indexOf("Q3 roadmap review") < html.indexOf("Devcon"),
125
+ "the 10th is drawn before the 11th",
126
+ );
127
+ });
128
+
129
+ it("names the free stretch the day still leaves open", () => {
130
+ const html = words(render());
131
+ assert.match(html, /10h free/);
132
+ assert.match(html, /12:00 – 22:00/);
133
+ });
134
+
135
+ it("says nothing is booked rather than drawing an empty day", () => {
136
+ assert.match(render(), /Free all day/);
137
+ });
138
+
139
+ it("collapses a run of empty days into one sentence", () => {
140
+ const html = words(render());
141
+ assert.match(html, /Fri 12 – Tue 16 Jun/);
142
+ assert.match(html, /5 days with nothing booked/);
143
+ });
144
+
145
+ it("keeps the day it was focused on out of a run", () => {
146
+ const html = words(
147
+ render({ focusDate: "2026-06-14", today: "2026-06-14" }),
148
+ );
149
+ assert.doesNotMatch(html, /5 days with nothing booked/);
150
+ });
151
+
152
+ it("names a pile-up and offers the grid for it", () => {
153
+ const html = words(render());
154
+ assert.match(html, /2 at once · 10:00 – 12:00/);
155
+ assert.match(html, /Open the grid/);
156
+ });
157
+
158
+ it("marks the day that is today", () => {
159
+ assert.match(render(), /Today/);
160
+ assert.match(render(), /Wednesday/);
161
+ });
162
+
163
+ it("counts the day rather than restating its rows", () => {
164
+ assert.match(render(), /2 events · 2h booked · 1 clash/);
165
+ });
166
+
167
+ it("carries the calendar's hue onto every event", () => {
168
+ assert.match(render(), /bg-cal-3-soft/);
169
+ });
170
+
171
+ it("falls back to one hue for a calendar it was told nothing about", () => {
172
+ assert.match(render({ calendars: [] }), /bg-cal-1-soft/);
173
+ });
174
+
175
+ it("draws a banner as an all-day line", () => {
176
+ assert.match(render(), /All day/);
177
+ });
178
+
179
+ it("says which event is selected rather than only colouring it", () => {
180
+ assert.match(
181
+ render({ selectedEventId: "evt_roadmap" }),
182
+ /aria-pressed="true"/,
183
+ );
184
+ });
185
+
186
+ it("shows where and who only at the detail reading", () => {
187
+ assert.doesNotMatch(render(), /Kaap/);
188
+ assert.match(words(render({ density: "detail" })), /Northwind · Kaap/);
189
+ });
190
+
191
+ it("gives every dot a name at the glance reading", () => {
192
+ const html = render({ density: "dots" });
193
+ assert.match(html, /aria-label="Q3 roadmap review"/);
194
+ assert.doesNotMatch(html, /2 at once/);
195
+ });
196
+
197
+ it("names what a glance day is worth in a few characters", () => {
198
+ const html = words(render({ density: "dots" }));
199
+ assert.match(html, /10h free/);
200
+ assert.match(html, /clear/);
201
+ });
202
+
203
+ it("gives every control a visible focus ring", () => {
204
+ assert.match(render(), /focus-visible:ring-ring/);
205
+ });
206
+
207
+ it("grows every hit target when the surface is touched", () => {
208
+ assert.match(render({ touch: true }), /min-h-12/);
209
+ });
210
+
211
+ it("renders whatever the owner leads today with", () => {
212
+ const html = render({
213
+ todayLead: createElement("p", null, "Next up in 30m"),
214
+ });
215
+ assert.match(html, /Next up in 30m/);
216
+ });
217
+ });
@@ -0,0 +1,215 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { buildCalendarDay, datesBetween } from "../lib/agenda-time.js";
4
+ import { AgendaFlow } from "./agenda-flow.js";
5
+ import type {
6
+ CalendarDescriptor,
7
+ CalendarEventData,
8
+ } from "./calendar-types.js";
9
+
10
+ /**
11
+ * The strip spends its pixels on what is on the day rather than on the hours
12
+ * the day contains. Every story here is a day the argument has to survive: a
13
+ * pile-up, a day with nothing but a banner, and a week nobody booked.
14
+ */
15
+ const meta: Meta<typeof AgendaFlow> = {
16
+ title: "Calendar/Agenda flow",
17
+ component: AgendaFlow,
18
+ parameters: { layout: "fullscreen" },
19
+ decorators: [
20
+ (Story) => (
21
+ <div className="flex h-[38rem] flex-col border border-line bg-surface">
22
+ <Story />
23
+ </div>
24
+ ),
25
+ ],
26
+ };
27
+ export default meta;
28
+
29
+ type Story = StoryObj<typeof AgendaFlow>;
30
+
31
+ const TODAY = "2026-06-10";
32
+ const OFFSET = "+02:00";
33
+
34
+ const calendars: CalendarDescriptor[] = [
35
+ {
36
+ id: "work",
37
+ accountId: "a1",
38
+ accountLabel: "Work",
39
+ name: "Northwind",
40
+ color: "cal-1",
41
+ },
42
+ {
43
+ id: "oncall",
44
+ accountId: "a1",
45
+ accountLabel: "Work",
46
+ name: "On-call",
47
+ color: "cal-4",
48
+ },
49
+ {
50
+ id: "personal",
51
+ accountId: "a2",
52
+ accountLabel: "Personal",
53
+ name: "Family",
54
+ color: "cal-3",
55
+ },
56
+ ];
57
+
58
+ function event(
59
+ id: string,
60
+ title: string,
61
+ calendarId: string,
62
+ date: string,
63
+ from: string,
64
+ to: string,
65
+ extra: Partial<CalendarEventData> = {},
66
+ ): CalendarEventData {
67
+ return {
68
+ id,
69
+ calendarId,
70
+ title,
71
+ start: `${date}T${from}:00${OFFSET}`,
72
+ end: `${date}T${to}:00${OFFSET}`,
73
+ allDay: false,
74
+ location: "",
75
+ notes: "",
76
+ attendees: [],
77
+ myRsvp: "accepted",
78
+ threadId: "",
79
+ threadSubject: "",
80
+ timeZone: "Europe/Amsterdam",
81
+ zoneCertainty: "explicit",
82
+ recurrenceRule: "",
83
+ seriesId: "",
84
+ seriesException: false,
85
+ status: "confirmed",
86
+ ...extra,
87
+ };
88
+ }
89
+
90
+ const events: CalendarEventData[] = [
91
+ event("evt_standup", "Standup", "work", TODAY, "09:00", "09:15", {
92
+ recurrenceRule: "Every weekday",
93
+ }),
94
+ event("evt_roadmap", "Q3 roadmap review", "work", TODAY, "10:00", "11:30", {
95
+ location: "Kaap",
96
+ threadId: "thr_roadmap",
97
+ attendees: [
98
+ {
99
+ name: "Anna Vos",
100
+ email: "anna@example.test",
101
+ rsvp: "accepted",
102
+ role: "organizer",
103
+ },
104
+ {
105
+ name: "Bram Peters",
106
+ email: "bram@example.test",
107
+ rsvp: "noReply",
108
+ role: "attendee",
109
+ },
110
+ ],
111
+ }),
112
+ event("evt_incident", "Incident review", "oncall", TODAY, "10:30", "12:00"),
113
+ event("evt_1to1", "1:1 with Anna", "work", TODAY, "11:00", "11:20"),
114
+ event("evt_lunch", "Lunch with Jane", "personal", TODAY, "12:30", "13:30", {
115
+ location: "Toscanini",
116
+ }),
117
+ event("evt_retro", "Retro", "work", TODAY, "16:00", "17:00", {
118
+ status: "tentative",
119
+ }),
120
+ event("evt_dentist", "Dentist", "personal", "2026-06-11", "14:00", "14:45", {
121
+ myRsvp: "declined",
122
+ }),
123
+ event("evt_call", "Lisbon call", "work", "2026-06-11", "17:00", "18:00", {
124
+ zoneCertainty: "ambiguous",
125
+ timeZone: "",
126
+ }),
127
+ {
128
+ ...event("evt_devcon", "Devcon", "work", "2026-06-12", "00:00", "00:00"),
129
+ start: "2026-06-12",
130
+ end: "2026-06-13",
131
+ allDay: true,
132
+ },
133
+ event("evt_offsite", "Offsite", "work", "2026-06-22", "09:00", "17:00"),
134
+ ];
135
+
136
+ const days = datesBetween("2026-06-08", "2026-06-24").map((date) =>
137
+ buildCalendarDay(date, events, TODAY),
138
+ );
139
+
140
+ const base = {
141
+ days,
142
+ calendars,
143
+ today: TODAY,
144
+ focusDate: TODAY,
145
+ selectedEventId: "",
146
+ onSelectEvent: () => {},
147
+ onPickSlot: () => {},
148
+ onZoomDay: () => {},
149
+ onReachStart: () => {},
150
+ onReachEnd: () => {},
151
+ onVisibleDayChange: () => {},
152
+ };
153
+
154
+ /** The default reading: one row an event, free time drawn between them. */
155
+ export const Rows: Story = {
156
+ args: { ...base, density: "pills" },
157
+ };
158
+
159
+ /** Where, who and which calendar, for a day you are actually working through. */
160
+ export const Detail: Story = {
161
+ args: { ...base, density: "detail" },
162
+ };
163
+
164
+ /** A month at a glance: colour, load and one word a day. */
165
+ export const Dots: Story = {
166
+ args: { ...base, density: "dots" },
167
+ };
168
+
169
+ /** A day with nothing on the clock says so instead of showing whitespace. */
170
+ export const ClearDay: Story = {
171
+ args: { ...base, density: "pills", focusDate: "2026-06-13" },
172
+ };
173
+
174
+ /** Nine days nobody booked, as one sentence rather than nine screens. */
175
+ export const EmptyRun: Story = {
176
+ args: { ...base, density: "pills", focusDate: "2026-06-18" },
177
+ };
178
+
179
+ /** The selection is a state of the row, not a colour laid over it. */
180
+ export const Selected: Story = {
181
+ args: { ...base, density: "detail", selectedEventId: "evt_roadmap" },
182
+ };
183
+
184
+ /** Every hit target grows where a finger has to find it. */
185
+ export const Touch: Story = {
186
+ args: { ...base, density: "pills", touch: true },
187
+ };
188
+
189
+ /** What is next, landed on with today and scrolled away with it. */
190
+ export const WithTodayLead: Story = {
191
+ args: {
192
+ ...base,
193
+ density: "pills",
194
+ todayLead: (
195
+ <p className="border-b border-line bg-surface-sunken px-row-inset py-2 text-xs text-fg-muted">
196
+ Next up · Q3 roadmap review in 30m
197
+ </p>
198
+ ),
199
+ },
200
+ };
201
+
202
+ /** Selecting a row is the only thing the strip owns; the owner holds the rest. */
203
+ export const Interactive: Story = {
204
+ render: () => {
205
+ const [selected, setSelected] = useState("");
206
+ return (
207
+ <AgendaFlow
208
+ {...base}
209
+ density="detail"
210
+ selectedEventId={selected}
211
+ onSelectEvent={setSelected}
212
+ />
213
+ );
214
+ },
215
+ };