@remit/ui 0.0.161 → 0.0.163

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.161",
3
+ "version": "0.0.163",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Who reaches the end of the strip.
3
+ *
4
+ * A sparse diary draws shorter than the distance either end is fetched at, so
5
+ * an end measured off the content alone is reached on the first layout pass and
6
+ * stays reached however many days arrive. That is how a two-event calendar
7
+ * walked its range out to 2032 and took the address with it: every prepend took
8
+ * the scroll offset back, every take-back raised a scroll event, and every
9
+ * scroll event asked for another fortnight at both ends.
10
+ *
11
+ * So reaching an end is something a reader does. The claims here are about who
12
+ * moved the strip — a mount, a resize and a font swap all move it and none of
13
+ * them may fetch — and they are asserted against a stubbed layout, because
14
+ * jsdom has none of its own and the bug lives entirely in the numbers.
15
+ */
16
+
17
+ import "@remit/test-dom";
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, beforeEach, describe, it } from "node:test";
20
+ import { act, createElement } from "react";
21
+ import { createRoot, type Root } from "react-dom/client";
22
+ import { buildCalendarDay } from "../lib/agenda-time.js";
23
+ import { AgendaFlow, type AgendaFlowProps } from "./agenda-flow.js";
24
+ import type {
25
+ CalendarDescriptor,
26
+ CalendarEventData,
27
+ } from "./calendar-types.js";
28
+
29
+ const TODAY = "2026-06-10";
30
+ const OFFSET = "+02:00";
31
+
32
+ const dates = [TODAY, "2026-06-11", "2026-06-12", "2026-06-13", "2026-06-14"];
33
+
34
+ const calendars: CalendarDescriptor[] = [
35
+ {
36
+ id: "c1",
37
+ accountId: "a1",
38
+ accountLabel: "Work",
39
+ name: "Northwind",
40
+ color: "cal-3",
41
+ },
42
+ ];
43
+
44
+ /** One booking a day, so every day keeps a row of its own to anchor against. */
45
+ const event = (date: string): CalendarEventData => ({
46
+ id: `evt_${date}`,
47
+ calendarId: "c1",
48
+ title: `Standup ${date}`,
49
+ start: `${date}T10:00:00${OFFSET}`,
50
+ end: `${date}T10:30:00${OFFSET}`,
51
+ allDay: false,
52
+ location: "",
53
+ notes: "",
54
+ attendees: [],
55
+ myRsvp: "accepted",
56
+ threadId: "",
57
+ threadSubject: "",
58
+ timeZone: "Europe/Amsterdam",
59
+ zoneCertainty: "explicit",
60
+ recurrenceRule: "",
61
+ seriesId: "",
62
+ seriesException: false,
63
+ status: "confirmed",
64
+ });
65
+
66
+ const days = dates.map((date) => buildCalendarDay(date, [event(date)], TODAY));
67
+
68
+ let container: HTMLElement;
69
+ let root: Root;
70
+ let reachedStart: number;
71
+ let reachedEnd: number;
72
+ let visited: string[];
73
+
74
+ /**
75
+ * A gesture goes stale, so the wall clock is one of the strip's inputs and the
76
+ * cases that turn on it hold it themselves.
77
+ */
78
+ const realNow = Date.now;
79
+ let clock = 0;
80
+ const advance = (ms: number) => {
81
+ clock += ms;
82
+ };
83
+
84
+ beforeEach(() => {
85
+ clock = 1_700_000_000_000;
86
+ Date.now = () => clock;
87
+ container = document.createElement("div");
88
+ document.body.appendChild(container);
89
+ root = createRoot(container);
90
+ reachedStart = 0;
91
+ reachedEnd = 0;
92
+ visited = [];
93
+ });
94
+
95
+ afterEach(() => {
96
+ act(() => root.unmount());
97
+ container.remove();
98
+ Date.now = realNow;
99
+ });
100
+
101
+ const props = (extra: Partial<AgendaFlowProps> = {}): AgendaFlowProps => ({
102
+ days,
103
+ calendars,
104
+ density: "pills",
105
+ today: TODAY,
106
+ focusDate: TODAY,
107
+ selectedEventId: "",
108
+ onSelectEvent: () => {},
109
+ onPickSlot: () => {},
110
+ onZoomDay: () => {},
111
+ onReachStart: () => {
112
+ reachedStart += 1;
113
+ },
114
+ onReachEnd: () => {
115
+ reachedEnd += 1;
116
+ },
117
+ onVisibleDayChange: (date) => {
118
+ visited.push(date);
119
+ },
120
+ ...extra,
121
+ });
122
+
123
+ const render = (extra: Partial<AgendaFlowProps> = {}) => {
124
+ act(() => root.render(createElement(AgendaFlow, props(extra))));
125
+ };
126
+
127
+ /**
128
+ * jsdom has no layout, so the strip is given one. `scrollTop` clamps the way a
129
+ * browser's does, which is what makes a pane taller than its content — the
130
+ * whole of the bug — reproducible here.
131
+ */
132
+ interface Strip {
133
+ element: HTMLElement;
134
+ /** An input the reader made on the strip, whatever kind it was. */
135
+ input: (event: Event) => void;
136
+ /** Where the strip ended up, and the event the browser raises to say so. */
137
+ moveTo: (top: number) => void;
138
+ /** The reader's own scroll: a wheel, then the offset it landed at. */
139
+ scroll: (top: number) => void;
140
+ /** The same movement with nobody behind it: a resize, a reflow, a landing. */
141
+ settle: (top: number) => void;
142
+ resize: (size: { scrollHeight: number; clientHeight: number }) => void;
143
+ }
144
+
145
+ const stripWithLayout = (scrollHeight: number, clientHeight: number): Strip => {
146
+ const element = container.querySelector<HTMLElement>(
147
+ '[data-testid="agenda-strip"]',
148
+ );
149
+ assert.ok(element, "the strip mounted");
150
+
151
+ const size = { scrollHeight, clientHeight };
152
+ let top = 0;
153
+ Object.defineProperty(element, "scrollHeight", {
154
+ configurable: true,
155
+ get: () => size.scrollHeight,
156
+ });
157
+ Object.defineProperty(element, "clientHeight", {
158
+ configurable: true,
159
+ get: () => size.clientHeight,
160
+ });
161
+ Object.defineProperty(element, "scrollTop", {
162
+ configurable: true,
163
+ get: () => top,
164
+ set: (next: number) => {
165
+ top = Math.max(0, Math.min(next, size.scrollHeight - size.clientHeight));
166
+ },
167
+ });
168
+ for (const [index, child] of [...element.children].entries()) {
169
+ Object.defineProperty(child, "offsetTop", {
170
+ configurable: true,
171
+ value: index * 200,
172
+ });
173
+ }
174
+
175
+ const raise = () => {
176
+ act(() => {
177
+ element.dispatchEvent(new Event("scroll"));
178
+ });
179
+ };
180
+
181
+ const input = (event: Event) => {
182
+ act(() => {
183
+ element.dispatchEvent(event);
184
+ });
185
+ };
186
+
187
+ const moveTo = (next: number) => {
188
+ element.scrollTop = next;
189
+ raise();
190
+ };
191
+
192
+ return {
193
+ element,
194
+ input,
195
+ moveTo,
196
+ scroll: (next) => {
197
+ input(new Event("wheel", { bubbles: true }));
198
+ moveTo(next);
199
+ },
200
+ settle: moveTo,
201
+ resize: (next) => {
202
+ Object.assign(size, next);
203
+ // A pane that shrank around a scroller takes its offset back with it,
204
+ // which is a scroll event nobody asked for.
205
+ const held = top;
206
+ element.scrollTop = held;
207
+ act(() => {
208
+ window.dispatchEvent(new Event("resize"));
209
+ });
210
+ raise();
211
+ },
212
+ };
213
+ };
214
+
215
+ /**
216
+ * Five days with one booking each. The strip stands barely taller than the pane
217
+ * and well inside the distance both ends are fetched at, which is the case the
218
+ * runaway needs: every end is reached, from the first layout pass onwards.
219
+ */
220
+ const SPARSE = { scrollHeight: 800, clientHeight: 640 };
221
+ /** Enough days that both ends are somewhere the reader has to go to reach. */
222
+ const LONG = { scrollHeight: 5_000, clientHeight: 640 };
223
+
224
+ describe("a strip shorter than the pane holding it", () => {
225
+ it("asks for no days at all when it mounts on the day the address named", () => {
226
+ render();
227
+ const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
228
+ strip.settle(0);
229
+ assert.equal(reachedEnd, 0);
230
+ assert.equal(reachedStart, 0);
231
+ assert.deepEqual(visited, []);
232
+ });
233
+
234
+ it("asks for none as the layout settles under it", () => {
235
+ render();
236
+ const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
237
+ // A font swaps, the rows re-measure, and the landing is re-applied against
238
+ // the height it now has. Every one of these raises a scroll event.
239
+ for (const top of [0, 24, 8, 0]) strip.settle(top);
240
+ assert.equal(reachedEnd, 0);
241
+ assert.equal(reachedStart, 0);
242
+ });
243
+
244
+ it("asks for none when the pane is resized around it", () => {
245
+ render();
246
+ const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
247
+ strip.settle(120);
248
+ strip.resize({ scrollHeight: 800, clientHeight: 240 });
249
+ strip.resize({ scrollHeight: 800, clientHeight: 900 });
250
+ assert.equal(reachedEnd, 0);
251
+ assert.equal(reachedStart, 0);
252
+ });
253
+
254
+ it("leaves the address where it is, having never moved for a reader", () => {
255
+ render();
256
+ const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
257
+ strip.settle(120);
258
+ strip.resize({ scrollHeight: 800, clientHeight: 900 });
259
+ assert.deepEqual(visited, []);
260
+ });
261
+ });
262
+
263
+ describe("a reader scrolling the strip", () => {
264
+ it("asks for the days ahead once, on reaching the end", () => {
265
+ render();
266
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
267
+ strip.scroll(LONG.scrollHeight - LONG.clientHeight);
268
+ assert.equal(reachedEnd, 1);
269
+ assert.equal(reachedStart, 0);
270
+ });
271
+
272
+ it("moves the day under the header with them, once", () => {
273
+ render();
274
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
275
+ strip.scroll(LONG.scrollHeight - LONG.clientHeight);
276
+ assert.equal(visited.length, 1);
277
+ assert.equal(visited[0], dates[dates.length - 1]);
278
+ });
279
+
280
+ it("asks again for nothing while it stands still at that end", () => {
281
+ render();
282
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
283
+ strip.scroll(LONG.scrollHeight - LONG.clientHeight);
284
+ strip.scroll(LONG.scrollHeight - LONG.clientHeight);
285
+ strip.settle(LONG.scrollHeight - LONG.clientHeight);
286
+ assert.equal(reachedEnd, 1);
287
+ });
288
+
289
+ /**
290
+ * Every way a reader moves a scroller counts, and the strip knows nothing
291
+ * about which one it was: a wheel, a key held on a focused row, a scrollbar
292
+ * dragged for as long as the reader likes. What it rejects is movement with
293
+ * nobody behind it, not movement of the wrong kind.
294
+ */
295
+ it("takes a key press for a gesture", () => {
296
+ render();
297
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
298
+ strip.input(
299
+ new KeyboardEvent("keydown", { key: "PageDown", bubbles: true }),
300
+ );
301
+ strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
302
+ assert.equal(reachedEnd, 1);
303
+ });
304
+
305
+ it("takes a scrollbar drag, however long the reader holds it", () => {
306
+ render();
307
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
308
+ strip.input(new PointerEvent("pointerdown", { bubbles: true }));
309
+ strip.moveTo(2_000);
310
+ // Long enough that the press alone has gone stale; the drag is what is
311
+ // still keeping the strip theirs.
312
+ advance(5_000);
313
+ strip.input(new PointerEvent("pointermove", { bubbles: true, buttons: 1 }));
314
+ strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
315
+ assert.equal(reachedEnd, 1);
316
+ });
317
+
318
+ it("does not take a cursor merely resting over it", () => {
319
+ render();
320
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
321
+ strip.input(new PointerEvent("pointermove", { bubbles: true, buttons: 0 }));
322
+ strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
323
+ assert.equal(reachedEnd, 0);
324
+ });
325
+
326
+ it("lets a gesture go stale rather than holding it open", () => {
327
+ render();
328
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
329
+ strip.input(new Event("wheel", { bubbles: true }));
330
+ advance(5_000);
331
+ strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
332
+ assert.equal(reachedEnd, 0);
333
+ });
334
+
335
+ it("asks for the days behind only on the way back to the start", () => {
336
+ render();
337
+ const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
338
+ strip.scroll(LONG.scrollHeight - LONG.clientHeight);
339
+ assert.equal(reachedStart, 0);
340
+ strip.scroll(80);
341
+ assert.equal(reachedStart, 1);
342
+ assert.equal(reachedEnd, 1);
343
+ });
344
+ });
345
+
346
+ describe("the end of what the strip grows to on its own", () => {
347
+ it("says so and offers the next stretch, rather than fetching it", () => {
348
+ let asked = 0;
349
+ render({
350
+ atEndCap: true,
351
+ onLoadLater: () => {
352
+ asked += 1;
353
+ },
354
+ });
355
+ const later = container.querySelector<HTMLElement>(
356
+ '[data-testid="agenda-load-later"]',
357
+ );
358
+ assert.ok(later, "the strip offered the days past the cap");
359
+ assert.match(later.textContent ?? "", /Show later days/);
360
+
361
+ act(() => {
362
+ later.dispatchEvent(new MouseEvent("click", { bubbles: true }));
363
+ });
364
+ assert.equal(asked, 1);
365
+ });
366
+
367
+ it("offers the days behind the same way", () => {
368
+ render({ atStartCap: true, onLoadEarlier: () => {} });
369
+ const earlier = container.querySelector(
370
+ '[data-testid="agenda-load-earlier"]',
371
+ );
372
+ assert.ok(earlier, "the strip offered the days before the cap");
373
+ });
374
+
375
+ it("shows neither while the run still has room to grow", () => {
376
+ render();
377
+ assert.equal(
378
+ container.querySelector('[data-testid^="agenda-load-"]'),
379
+ null,
380
+ );
381
+ });
382
+ });
@@ -176,6 +176,22 @@ export const EmptyRun: Story = {
176
176
  args: { ...base, density: "pills", focusDate: "2026-06-18" },
177
177
  };
178
178
 
179
+ /**
180
+ * A year either way is as far as the strip grows on the scroll. Past that the
181
+ * reader says so, rather than the strip fetching its way across a decade
182
+ * because a sparse diary never fills the pane.
183
+ */
184
+ export const AtTheCap: Story = {
185
+ args: {
186
+ ...base,
187
+ density: "pills",
188
+ atStartCap: true,
189
+ atEndCap: true,
190
+ onLoadEarlier: () => {},
191
+ onLoadLater: () => {},
192
+ },
193
+ };
194
+
179
195
  /** The selection is a state of the row, not a colour laid over it. */
180
196
  export const Selected: Story = {
181
197
  args: { ...base, density: "detail", selectedEventId: "evt_roadmap" },
@@ -11,6 +11,13 @@
11
11
  * Scrolling never paginates. Reaching either end asks the owner for more days
12
12
  * and the scroll position is held across the insert, so the strip has no seams
13
13
  * and no page boundaries to lose your place at.
14
+ *
15
+ * Reaching an end is something a reader does, never something a layout is. A
16
+ * sparse diary draws shorter than the distance either end is fetched at, so an
17
+ * end measured off the content alone is reached the moment the strip mounts and
18
+ * stays reached however many days arrive — which walked the range, and the
19
+ * address with it, out into empty years. What asks for more days is a scroll
20
+ * the reader drove, moving toward the end it asks about.
14
21
  */
15
22
  import { CalendarOff, ChevronRight, Layers, MapPin, Users } from "lucide-react";
16
23
  import {
@@ -64,6 +71,17 @@ const LONG_FREE_MINUTES = 150;
64
71
  /** The hue every calendar the caller did not describe falls back to. */
65
72
  const FALLBACK_COLOR: CalendarColorId = "cal-1";
66
73
 
74
+ /** How close to either end a scroll has to come before more days are asked for. */
75
+ const REACH_BEHIND = 600;
76
+ const REACH_AHEAD = 900;
77
+
78
+ /**
79
+ * How long after a wheel, a drag, a touch or a key a scroll is still the
80
+ * reader's. Momentum and a held key both keep scrolling after the input stops;
81
+ * a layout pass or a resize minutes later does not get to borrow the gesture.
82
+ */
83
+ const INTENT_MS = 1_500;
84
+
67
85
  /** Nothing outstanding, which is what a caller that never fetches has. */
68
86
  const EMPTY_DATES: ReadonlySet<string> = new Set();
69
87
 
@@ -105,6 +123,15 @@ export interface AgendaFlowProps {
105
123
  onReachEnd: () => void;
106
124
  /** The day under the sticky header, for the position map. */
107
125
  onVisibleDayChange: (date: string) => void;
126
+ /**
127
+ * The run has grown as far as it goes without being asked. The strip says so
128
+ * at that end and offers the reader the next stretch, rather than fetching
129
+ * its way across a decade nobody scrolled to.
130
+ */
131
+ atStartCap?: boolean;
132
+ atEndCap?: boolean;
133
+ onLoadEarlier?: () => void;
134
+ onLoadLater?: () => void;
108
135
  /**
109
136
  * Days whose events have not arrived. They draw as a skeleton and are never
110
137
  * collapsed into a run: a day nobody has heard back about is not a day with
@@ -142,6 +169,10 @@ export function AgendaFlow({
142
169
  onReachStart,
143
170
  onReachEnd,
144
171
  onVisibleDayChange,
172
+ atStartCap = false,
173
+ atEndCap = false,
174
+ onLoadEarlier,
175
+ onLoadLater,
145
176
  loadingDates = EMPTY_DATES,
146
177
  freeOn = freeStretchesOn,
147
178
  scrollTarget,
@@ -156,6 +187,15 @@ export function AgendaFlow({
156
187
  const askedStart = useRef("");
157
188
  const askedEnd = useRef("");
158
189
  const landed = useRef(false);
190
+ /*
191
+ * Where the scroller was left, so the next scroll event can be read as a
192
+ * movement and in which direction. Every offset this component writes itself
193
+ * records itself here, which is what makes the take-back after a prepend, and
194
+ * the landing, read as no movement at all.
195
+ */
196
+ const resting = useRef(0);
197
+ /** When the reader last touched the strip. */
198
+ const reached = useRef(0);
159
199
  const [visibleDate, setVisibleDate] = useState(focusDate);
160
200
 
161
201
  const lookup = useMemo(() => lookupOf(calendars), [calendars]);
@@ -172,6 +212,7 @@ export function AgendaFlow({
172
212
  element.scrollTop += element.scrollHeight - previousHeight.current;
173
213
  previousFirst.current = firstDate;
174
214
  previousHeight.current = element.scrollHeight;
215
+ resting.current = element.scrollTop;
175
216
  });
176
217
 
177
218
  /* Landing puts you on the focused day; after that the strip is yours. Rows
@@ -188,6 +229,7 @@ export function AgendaFlow({
188
229
  const anchor = anchors.current.get(focusDate);
189
230
  if (dropped || !anchor) return;
190
231
  element.scrollTop = anchor.offsetTop - HEADER_HEIGHT;
232
+ resting.current = element.scrollTop;
191
233
  };
192
234
  settle();
193
235
  setVisibleDate(focusDate);
@@ -205,20 +247,66 @@ export function AgendaFlow({
205
247
  const anchor = anchors.current.get(scrollTarget.date);
206
248
  if (!element || !anchor) return;
207
249
  element.scrollTop = anchor.offsetTop - HEADER_HEIGHT;
250
+ resting.current = element.scrollTop;
208
251
  setVisibleDate(scrollTarget.date);
209
252
  onVisibleDayChange(scrollTarget.date);
210
253
  }, [scrollTarget, onVisibleDayChange]);
211
254
 
255
+ /*
256
+ * A wheel, a drag, a touch or a key: the strip is the reader's from here.
257
+ * Listened for natively rather than through React, because a `div` carrying
258
+ * key and pointer handlers has to claim a role to be one, and the strip is a
259
+ * scroller rather than a control — announcing it as one would be a lie told
260
+ * to a screen reader for the sake of a lint rule.
261
+ */
262
+ useEffect(() => {
263
+ const element = scroller.current;
264
+ if (!element) return;
265
+ const note = () => {
266
+ reached.current = Date.now();
267
+ };
268
+ const kinds = [
269
+ "wheel",
270
+ "pointerdown",
271
+ "touchstart",
272
+ "touchmove",
273
+ "keydown",
274
+ ];
275
+ // A scrollbar dragged slowly is one press and then nothing but movement,
276
+ // which would otherwise run out of gesture halfway down the strip. Held
277
+ // buttons only: a cursor resting over the strip is not scrolling it.
278
+ const drag = (event: PointerEvent) => {
279
+ if (event.buttons !== 0) note();
280
+ };
281
+ for (const kind of kinds)
282
+ element.addEventListener(kind, note, { passive: true });
283
+ element.addEventListener("pointermove", drag, { passive: true });
284
+ return () => {
285
+ for (const kind of kinds) element.removeEventListener(kind, note);
286
+ element.removeEventListener("pointermove", drag);
287
+ };
288
+ }, []);
289
+
212
290
  const handleScroll = () => {
213
291
  const element = scroller.current;
214
292
  if (!element) return;
215
293
 
294
+ const top = element.scrollTop;
295
+ const moved = top - resting.current;
296
+ resting.current = top;
297
+
298
+ /* Everything below follows the reader and only the reader. A mount, a
299
+ resize, a font swap and the offset taken back after a prepend all raise
300
+ this event without anybody having scrolled, and answering them is how
301
+ the range and the address walked off on their own. */
302
+ if (moved === 0 || Date.now() - reached.current > INTENT_MS) return;
303
+
216
304
  const seen = [...anchors.current.entries()]
217
305
  .filter(([, node]) => node.isConnected)
218
306
  .sort((a, b) => a[1].offsetTop - b[1].offsetTop);
219
307
  let current = "";
220
308
  for (const [date, node] of seen) {
221
- if (node.offsetTop - HEADER_HEIGHT - 8 > element.scrollTop) break;
309
+ if (node.offsetTop - HEADER_HEIGHT - 8 > top) break;
222
310
  current = date;
223
311
  }
224
312
  if (current !== "" && current !== visibleDate) {
@@ -226,12 +314,13 @@ export function AgendaFlow({
226
314
  onVisibleDayChange(current);
227
315
  }
228
316
 
229
- if (element.scrollTop < 600 && askedStart.current !== firstDate) {
317
+ if (moved < 0 && top < REACH_BEHIND && askedStart.current !== firstDate) {
230
318
  askedStart.current = firstDate;
231
319
  onReachStart();
232
320
  }
233
321
  if (
234
- element.scrollTop + element.clientHeight > element.scrollHeight - 900 &&
322
+ moved > 0 &&
323
+ top + element.clientHeight > element.scrollHeight - REACH_AHEAD &&
235
324
  askedEnd.current !== lastDate
236
325
  ) {
237
326
  askedEnd.current = lastDate;
@@ -268,6 +357,15 @@ export function AgendaFlow({
268
357
  )}
269
358
  </div>
270
359
 
360
+ {atStartCap && onLoadEarlier && (
361
+ <CapEdge
362
+ label="Show earlier days"
363
+ testId="agenda-load-earlier"
364
+ onLoad={onLoadEarlier}
365
+ touch={touch}
366
+ />
367
+ )}
368
+
271
369
  {rows.map((row) => (
272
370
  <FlowRow
273
371
  key={row.key}
@@ -288,10 +386,51 @@ export function AgendaFlow({
288
386
  touch={touch}
289
387
  />
290
388
  ))}
389
+
390
+ {atEndCap && onLoadLater && (
391
+ <CapEdge
392
+ label="Show later days"
393
+ testId="agenda-load-later"
394
+ onLoad={onLoadLater}
395
+ touch={touch}
396
+ />
397
+ )}
291
398
  </div>
292
399
  );
293
400
  }
294
401
 
402
+ /**
403
+ * Where the run stops growing on its own. A year either way is further than a
404
+ * reader scrolls in one sitting, so this is rarely on screen — and when it is,
405
+ * the next stretch costs a click rather than arriving because the strip decided
406
+ * it had reached the end of itself.
407
+ */
408
+ function CapEdge({
409
+ label,
410
+ testId,
411
+ onLoad,
412
+ touch,
413
+ }: {
414
+ label: string;
415
+ testId: string;
416
+ onLoad: () => void;
417
+ touch?: boolean;
418
+ }) {
419
+ return (
420
+ <button
421
+ type="button"
422
+ onClick={onLoad}
423
+ data-testid={testId}
424
+ className={cn(
425
+ "flex w-full items-center justify-center gap-2 border-y border-dashed border-line bg-surface-sunken px-row-inset text-xs font-medium text-fg-muted outline-none transition-colors hover:border-accent hover:text-accent focus-visible:ring-2 focus-visible:ring-ring",
426
+ touch ? "min-h-14 py-3" : "py-2",
427
+ )}
428
+ >
429
+ {label}
430
+ </button>
431
+ );
432
+ }
433
+
295
434
  /* ------------------------------------------------------------------ */
296
435
  /* Rows */
297
436
  /* ------------------------------------------------------------------ */
@@ -1,8 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { AtSign, Inbox, Server } from "lucide-react";
3
3
  import { useState } from "react";
4
+ import { Button } from "./button.js";
4
5
  import type { ServerSecurity } from "./security-select.js";
5
- import { ConnectorTile, ServerFields } from "./wizard.js";
6
+ import { ConnectorTile, ServerFields, WizardShell } from "./wizard.js";
6
7
 
7
8
  const meta: Meta = {
8
9
  title: "Components/Wizard",
@@ -101,3 +102,53 @@ export const ServerFieldsPhone: Story = {
101
102
  globals: { viewport: { value: "mobile" } },
102
103
  render: () => <ServerFieldsDemo />,
103
104
  };
105
+
106
+ const longStepRows = Array.from(
107
+ { length: 60 },
108
+ (_, index) => `Row ${index + 1} of a report nobody sized the box for`,
109
+ );
110
+
111
+ function LongStep() {
112
+ return (
113
+ <WizardShell
114
+ steps={["File", "Review", "Credentials", "Folders"]}
115
+ activeStep={1}
116
+ title="What this file will change"
117
+ subtitle="A step whose content runs well past the bottom of the screen."
118
+ footer={
119
+ <>
120
+ <Button variant="ghost">Back</Button>
121
+ <Button variant="primary">Continue</Button>
122
+ </>
123
+ }
124
+ >
125
+ <ul className="divide-y divide-line">
126
+ {longStepRows.map((row) => (
127
+ <li key={row} className="py-2 text-sm text-fg">
128
+ {row}
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ </WizardShell>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * A step taller than the viewport, on the 1512×827 laptop the config import
138
+ * wizard was found unusable on (#1021). The rows scroll inside the card; the
139
+ * title and the Continue button stay on screen.
140
+ */
141
+ export const ShellLongStepShortViewport: Story = {
142
+ name: "WizardShell — long step, short viewport",
143
+ parameters: { layout: "fullscreen" },
144
+ globals: { viewport: { value: "laptopShort" } },
145
+ render: () => <LongStep />,
146
+ };
147
+
148
+ /** The same step on a phone with the address bar and system nav showing. */
149
+ export const ShellLongStepPhoneShort: Story = {
150
+ name: "WizardShell — long step, short phone",
151
+ parameters: { layout: "fullscreen" },
152
+ globals: { viewport: { value: "mobileShort" } },
153
+ render: () => <LongStep />,
154
+ };
@@ -76,25 +76,40 @@ export function WizardShell({
76
76
  hideSteps,
77
77
  }: WizardShellProps) {
78
78
  return (
79
- // First-run is a focal moment: the wizard column is horizontally
80
- // centered with its top edge anchored ~30% down the viewport
81
- // (optical center) on larger screens. On phone the top padding is
82
- // minimal so taller steps (connector picker, servers) don't push the
83
- // CTA bar below the fold — the shell scrolls if content overflows.
84
- <div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(2rem,env(safe-area-inset-left,0px))] pr-[max(2rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] font-sans text-fg sm:pt-[30vh]">
79
+ // The shell is exactly the viewport, never more: the step's own content
80
+ // is what scrolls, and the title and the footer actions are pinned
81
+ // either side of it. A dry-run report or a list of accounts can run to
82
+ // any length and the primary action stays where the reader can press it
83
+ // (#1021).
84
+ //
85
+ // First-run is still a focal moment, so on larger screens the column
86
+ // sits with its top edge about 30% down the viewport (optical centre).
87
+ // That offset is a flex spacer rather than top padding because padding
88
+ // cannot yield: the spacer collapses first and the card only gives up
89
+ // height once the offset is gone.
90
+ <div className="flex h-dvh w-full flex-col items-center overflow-hidden bg-canvas pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(2rem,env(safe-area-inset-left,0px))] pr-[max(2rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] font-sans text-fg">
91
+ <div className="hidden shrink-[9999] basis-[30vh] sm:block" />
85
92
  {!hideSteps && (
86
- <div className="mb-5 w-full max-w-xl">
93
+ <div className="mb-5 w-full max-w-xl shrink-0">
87
94
  <StepRail steps={steps} activeStep={activeStep} />
88
95
  </div>
89
96
  )}
90
- <Card raised className="w-full max-w-xl">
91
- <div className="px-5 pt-5">
97
+ <Card raised className="flex w-full min-h-0 max-w-xl flex-col">
98
+ <div className="shrink-0 px-5 pt-5">
92
99
  <h1 className="text-xl font-semibold text-fg">{title}</h1>
93
100
  {subtitle && <p className="mt-1 text-sm text-fg-muted">{subtitle}</p>}
94
101
  </div>
95
- <div className="px-5 py-5">{children}</div>
102
+ <div
103
+ data-testid="wizard-body"
104
+ className="min-h-0 overflow-y-auto px-5 py-5"
105
+ >
106
+ {children}
107
+ </div>
96
108
  {footer && (
97
- <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
109
+ <div
110
+ data-testid="wizard-footer"
111
+ className="flex shrink-0 items-center justify-between gap-3 border-t border-line px-5 py-3"
112
+ >
98
113
  {footer}
99
114
  </div>
100
115
  )}