@boyernick/standard-ui-react 0.1.1-canary.9 → 0.2.0

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 (82) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +27 -12
  17. package/src/dialog.tsx +33 -10
  18. package/src/empty.tsx +4 -4
  19. package/src/field.tsx +4 -3
  20. package/src/filter-group.tsx +143 -0
  21. package/src/icons.tsx +28 -1
  22. package/src/illustrations.tsx +219 -141
  23. package/src/index.ts +238 -18
  24. package/src/input.tsx +8 -5
  25. package/src/kbd.tsx +10 -4
  26. package/src/lib/focus.ts +41 -0
  27. package/src/lib/popup.ts +1 -1
  28. package/src/lifeline/company-icon.tsx +71 -0
  29. package/src/lifeline/index.ts +42 -0
  30. package/src/lifeline/lifeline-data.ts +97 -0
  31. package/src/lifeline/lifeline-desktop.tsx +204 -0
  32. package/src/lifeline/lifeline-event.tsx +108 -0
  33. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  34. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  35. package/src/lifeline/lifeline-icons.tsx +39 -0
  36. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  37. package/src/lifeline/lifeline-labels.tsx +24 -0
  38. package/src/lifeline/lifeline-layout.ts +1 -0
  39. package/src/lifeline/lifeline-legend.tsx +31 -0
  40. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  41. package/src/lifeline/lifeline-marker.tsx +183 -0
  42. package/src/lifeline/lifeline-people.tsx +99 -0
  43. package/src/lifeline/lifeline-photos.tsx +366 -0
  44. package/src/lifeline/lifeline-shell.tsx +135 -0
  45. package/src/lifeline/lifeline-utils.ts +83 -0
  46. package/src/lifeline/lifeline-vertical.tsx +482 -0
  47. package/src/lifeline/lifeline.tsx +69 -0
  48. package/src/lifeline/types.ts +112 -0
  49. package/src/lifeline/use-lifeline-intro.ts +130 -0
  50. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  51. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  52. package/src/menubar.tsx +9 -2
  53. package/src/minimap.tsx +296 -0
  54. package/src/modal.tsx +608 -0
  55. package/src/navigation-menu.tsx +338 -67
  56. package/src/number-field.tsx +336 -61
  57. package/src/otp-field.tsx +4 -3
  58. package/src/pagination.tsx +262 -42
  59. package/src/password-protection.tsx +345 -0
  60. package/src/popover.tsx +1 -1
  61. package/src/progress.tsx +5 -0
  62. package/src/questionnaire.tsx +284 -0
  63. package/src/radio.tsx +4 -2
  64. package/src/scroll-area.tsx +4 -1
  65. package/src/select.tsx +5 -2
  66. package/src/sidebar.tsx +113 -28
  67. package/src/slider.tsx +66 -6
  68. package/src/sounds.tsx +6 -10
  69. package/src/spinner.tsx +105 -32
  70. package/src/switch.tsx +4 -1
  71. package/src/table.tsx +82 -15
  72. package/src/tabs.tsx +189 -38
  73. package/src/text-animate.tsx +71 -28
  74. package/src/textarea.tsx +6 -1
  75. package/src/timeline.tsx +378 -0
  76. package/src/toast.tsx +214 -35
  77. package/src/toggle.tsx +4 -2
  78. package/src/toolbar.tsx +12 -7
  79. package/src/tooltip.tsx +43 -3
  80. package/src/video-player.tsx +396 -127
  81. package/src/image-modal.tsx +0 -110
  82. package/src/markdown-editor.tsx +0 -302
@@ -0,0 +1,1011 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
4
+ import {
5
+ LIFELINE_STICKY_LEFT,
6
+ LIFELINE_STICKY_SHIELD_WIDTH,
7
+ } from "./lifeline-labels"
8
+ import { clamp, snapToDevicePixel } from "./lifeline-utils"
9
+ import type { LifelineMode } from "./types"
10
+
11
+ const FADE_ZONE = 200
12
+ const FADE_ZONE_COARSE = 72
13
+ const LEFT_EXIT_FADE_ZONE = 400
14
+ const LEFT_EXIT_FADE_ZONE_COARSE = 160
15
+ const WHEEL_SPEED = 1.4
16
+ const WHEEL_VELOCITY_FRAME_MS = 16.67
17
+ const WHEEL_MOMENTUM_BLEND = 0.65
18
+ const DRAG_SPEED = 1
19
+ const TOUCH_DRAG_SPEED = 1.15
20
+ const TOUCH_GESTURE_LOCK_PX = 8
21
+ const NAV_HORIZONTAL_PADDING = 24
22
+ const MOMENTUM_FRICTION = 0.94
23
+ const MOMENTUM_MIN_VELOCITY = 0.025
24
+ const MOMENTUM_MIN_START = 0.08
25
+
26
+ /** Where the track starts when there is no host nav to align with. */
27
+ const LIFELINE_DEFAULT_START_INSET = 24
28
+
29
+ /** Page mode needs the stage to cover at least this much of the viewport. */
30
+ const PAGE_MODE_VIEWPORT_COVERAGE = 0.5
31
+ /** Scroll heights within this many px of the client height aren't scrollable. */
32
+ const PAGE_MODE_SCROLL_SLOP = 4
33
+ /** How long a resolved mode is trusted before it is measured again. */
34
+ const MODE_RESOLVE_STALE_MS = 250
35
+
36
+ /** A gap this long in the wheel stream ends one gesture and starts the next. */
37
+ const WHEEL_GESTURE_QUIET_MS = 120
38
+ /**
39
+ * Embedded: once the rail bottoms out, the wheel keeps being swallowed
40
+ * until the stream has been quiet this long. Trackpad inertia arrives as
41
+ * one unbroken stream, so a fast flick that eats the last of the rail
42
+ * dies here instead of spilling into the page behind it.
43
+ */
44
+ const EMBED_BOUNDARY_QUIET_MS = 260
45
+ /**
46
+ * …but never hold longer than this. Inertia decays; a finger or a wheel
47
+ * that is still going does not, so a sustained scroll gets through.
48
+ */
49
+ const EMBED_BOUNDARY_MAX_HOLD_MS = 900
50
+
51
+ /**
52
+ * How early the sweep is armed, relative to the module entering view. A
53
+ * couple of hundred pixels means it is already moving by the time it is
54
+ * properly on screen, rather than starting cold under the reader's eyes.
55
+ */
56
+ const EMBED_INTRO_ARM_MARGIN = "0px 0px 200px 0px"
57
+
58
+ function normalizeWheelDelta(event: WheelEvent) {
59
+ let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
60
+ ? event.deltaX
61
+ : event.deltaY
62
+
63
+ if (event.deltaMode === 1) delta *= 16
64
+ if (event.deltaMode === 2) delta *= window.innerHeight
65
+
66
+ return delta
67
+ }
68
+
69
+ function isInteractiveTarget(target: EventTarget | null) {
70
+ // Pointer capture during drag retargets clicks to the section, so
71
+ // anything clickable must opt out of drag-start here.
72
+ return (
73
+ target instanceof Element &&
74
+ Boolean(target.closest("a, button, [data-lifeline-interactive]"))
75
+ )
76
+ }
77
+
78
+ function isEditableTarget(target: EventTarget | null) {
79
+ return (
80
+ target instanceof Element &&
81
+ Boolean(target.closest("input, textarea, select, [contenteditable]"))
82
+ )
83
+ }
84
+
85
+ /** How much of the viewport this element currently covers, 0..1. */
86
+ function getViewportCoverage(element: HTMLElement) {
87
+ const rect = element.getBoundingClientRect()
88
+ const visibleX =
89
+ Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0)
90
+ const visibleY =
91
+ Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)
92
+ if (visibleX <= 0 || visibleY <= 0) return 0
93
+
94
+ return (visibleX * visibleY) / (window.innerWidth * window.innerHeight)
95
+ }
96
+
97
+ /**
98
+ * Is there a vertical scroll behind this element for a released wheel to
99
+ * drive? Walks out to the document, stopping at the first ancestor that
100
+ * clips — a `LifelineShell` is `overflow-hidden`, so nothing escapes it.
101
+ */
102
+ function hasReleasableScroll(section: HTMLElement) {
103
+ let node = section.parentElement
104
+
105
+ while (node) {
106
+ const { overflowY } = window.getComputedStyle(node)
107
+
108
+ if (overflowY === "hidden" || overflowY === "clip") return false
109
+
110
+ if (
111
+ (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
112
+ node.scrollHeight > node.clientHeight + PAGE_MODE_SCROLL_SLOP
113
+ ) {
114
+ return true
115
+ }
116
+
117
+ node = node.parentElement
118
+ }
119
+
120
+ const doc = document.scrollingElement
121
+ return Boolean(doc && doc.scrollHeight > doc.clientHeight + PAGE_MODE_SCROLL_SLOP)
122
+ }
123
+
124
+ interface LifelineScrollOptions {
125
+ mode?: LifelineMode
126
+ isCoarsePointer?: boolean
127
+ introLocked?: boolean
128
+ introAnimating?: boolean
129
+ introSkipped?: boolean
130
+ introRailMs?: number
131
+ introGetTrackProgress?: (elapsedMs: number) => number
132
+ onIntroSettleComplete?: () => void
133
+ onIntroScrollStart?: () => void
134
+ }
135
+
136
+ export function useLifelineScroll(
137
+ markerCount: number,
138
+ options: LifelineScrollOptions = {},
139
+ ) {
140
+ const sectionRef = useRef<HTMLDivElement>(null)
141
+ const trackRef = useRef<HTMLDivElement>(null)
142
+ const labelsRef = useRef<HTMLDivElement>(null)
143
+ const markerRefs = useRef<(HTMLDivElement | null)[]>([])
144
+ const maxTranslate = useRef(0)
145
+ const startInset = useRef(LIFELINE_DEFAULT_START_INSET)
146
+ const endInset = useRef(0)
147
+ const translatePx = useRef(0)
148
+ const initialized = useRef(false)
149
+ const dragging = useRef(false)
150
+ const gestureAxis = useRef<"x" | "y" | null>(null)
151
+ const gestureStart = useRef({ x: 0, y: 0 })
152
+ const dragOrigin = useRef({ x: 0, translate: 0 })
153
+ const dragVelocity = useRef(0)
154
+ const lastPointerSample = useRef({ x: 0, t: 0 })
155
+ const activePointerId = useRef<number | null>(null)
156
+ const isCoarsePointerRef = useRef(options.isCoarsePointer ?? false)
157
+ const momentumId = useRef(0)
158
+ const settleId = useRef(0)
159
+ const introLockedRef = useRef(options.introLocked ?? false)
160
+ const introAnimatingRef = useRef(options.introAnimating ?? false)
161
+ const introSkippedRef = useRef(options.introSkipped ?? false)
162
+ const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)
163
+ const onIntroScrollStartRef = useRef(options.onIntroScrollStart)
164
+ const settlingRef = useRef(false)
165
+ const introWasAnimatingRef = useRef(false)
166
+ const introScrollId = useRef(0)
167
+ const introScrollStart = useRef(0)
168
+ const introStartedRef = useRef(false)
169
+ const introGetTrackProgressRef = useRef(options.introGetTrackProgress)
170
+ const applyTranslateRef = useRef<(value: number) => void>(() => {})
171
+ const scheduleMeasureRef = useRef<() => void>(() => {})
172
+ const modeRef = useRef<LifelineMode>(options.mode ?? "auto")
173
+ const isEmbedRef = useRef(false)
174
+ const modeResolvedAt = useRef(0)
175
+ const prefersReducedMotionRef = useRef(false)
176
+ const wheelStreamAt = useRef(0)
177
+ const wheelGapMs = useRef(Number.POSITIVE_INFINITY)
178
+ const gestureStartedHere = useRef(false)
179
+ const gestureReleased = useRef(false)
180
+ const boundaryHitAt = useRef(0)
181
+ const [isLayoutReady, setIsLayoutReady] = useState(false)
182
+ const [isEmbed, setIsEmbed] = useState(false)
183
+ const [introArmed, setIntroArmed] = useState(false)
184
+
185
+ modeRef.current = options.mode ?? "auto"
186
+ isCoarsePointerRef.current = options.isCoarsePointer ?? false
187
+ introLockedRef.current = options.introLocked ?? false
188
+ introAnimatingRef.current = options.introAnimating ?? false
189
+ introSkippedRef.current = options.introSkipped ?? false
190
+ onIntroSettleCompleteRef.current = options.onIntroSettleComplete
191
+ onIntroScrollStartRef.current = options.onIntroScrollStart
192
+ introGetTrackProgressRef.current = options.introGetTrackProgress
193
+
194
+ const setMarkerRef = useCallback(
195
+ (index: number, node: HTMLDivElement | null) => {
196
+ markerRefs.current[index] = node
197
+
198
+ if (index === markerCount - 1 && node) {
199
+ scheduleMeasureRef.current()
200
+ }
201
+ },
202
+ [markerCount],
203
+ )
204
+
205
+ const getLabelStageLeft = useCallback((translate: number) => {
206
+ const section = sectionRef.current
207
+ if (!section) return { isSticky: false, labelLeft: 0 }
208
+
209
+ // Stage-relative: labels pin LIFELINE_STICKY_LEFT px inside the
210
+ // section's own left edge, wherever the section sits on the page.
211
+ const naturalLeft = startInset.current - translate
212
+ const isSticky = naturalLeft <= LIFELINE_STICKY_LEFT
213
+
214
+ return {
215
+ isSticky,
216
+ labelLeft: isSticky ? LIFELINE_STICKY_LEFT : naturalLeft,
217
+ }
218
+ }, [])
219
+
220
+ const applyLabelSticky = useCallback(
221
+ (translate: number) => {
222
+ const labels = labelsRef.current
223
+ const { isSticky, labelLeft } = getLabelStageLeft(translate)
224
+
225
+ if (!labels) return { isSticky, labelLeft }
226
+
227
+ if (isSticky) {
228
+ // Derived from the track's snapped offset so the two transforms
229
+ // cancel to exactly LIFELINE_STICKY_LEFT — the pinned labels
230
+ // must not drift a fraction against the snapped track.
231
+ const labelExtra =
232
+ LIFELINE_STICKY_LEFT -
233
+ snapToDevicePixel(startInset.current - translate)
234
+ labels.style.transform = `translate3d(${labelExtra}px, 0, 0)`
235
+ labels.classList.add("is-pinned")
236
+ } else {
237
+ labels.style.transform = ""
238
+ labels.classList.remove("is-pinned")
239
+ }
240
+
241
+ return { isSticky, labelLeft }
242
+ },
243
+ [getLabelStageLeft],
244
+ )
245
+
246
+ const isScrollLocked = useCallback(() => {
247
+ return (
248
+ (introLockedRef.current && introStartedRef.current) ||
249
+ settlingRef.current
250
+ )
251
+ }, [])
252
+
253
+ const updateFades = useCallback(() => {
254
+ if (settlingRef.current) return
255
+
256
+ const section = sectionRef.current
257
+ if (!section) return
258
+
259
+ // All fade math is relative to the section's own box — the lifeline
260
+ // may be embedded anywhere, not pinned to the viewport.
261
+ const stageRect = section.getBoundingClientRect()
262
+ const isCoarse = isCoarsePointerRef.current
263
+ const fadeZone = isCoarse ? FADE_ZONE_COARSE : FADE_ZONE
264
+ const leftFadeZone = isCoarse
265
+ ? LEFT_EXIT_FADE_ZONE_COARSE
266
+ : LEFT_EXIT_FADE_ZONE
267
+
268
+ markerRefs.current.forEach((marker) => {
269
+ if (!marker) return
270
+
271
+ const rect = marker.getBoundingClientRect()
272
+ const markerLeft = rect.left - stageRect.left
273
+ const center = markerLeft + rect.width / 2
274
+
275
+ let opacity = 1
276
+
277
+ // Fade a marker out only as scrubbing carries it left of where
278
+ // it rests at translate 0 — the first markers naturally live
279
+ // inside the fade zone and must not open dimmed.
280
+ const naturalLeft = markerLeft + translatePx.current
281
+ const restLeft = Math.min(naturalLeft, leftFadeZone)
282
+ if (markerLeft < restLeft) {
283
+ opacity = markerLeft <= 0 ? 0 : markerLeft / restLeft
284
+ }
285
+
286
+ if (center > stageRect.width - fadeZone) {
287
+ opacity = Math.min(opacity, (stageRect.width - center) / fadeZone)
288
+ }
289
+
290
+ if (isCoarse) {
291
+ const readableLeft = LIFELINE_STICKY_SHIELD_WIDTH
292
+ const readableRight = stageRect.width - 12
293
+ const markerRight = rect.right - stageRect.left
294
+ const visibleWidth =
295
+ Math.min(markerRight, readableRight) - Math.max(markerLeft, readableLeft)
296
+ const visibility = rect.width > 0 ? visibleWidth / rect.width : 0
297
+
298
+ if (visibility >= 0.5) {
299
+ opacity = 1
300
+ }
301
+ }
302
+
303
+ marker.style.opacity = String(clamp(opacity, 0, 1))
304
+ })
305
+ }, [])
306
+
307
+ const applyTranslate = useCallback(
308
+ (value: number) => {
309
+ const max = maxTranslate.current
310
+ const next = clamp(value, 0, max)
311
+ translatePx.current = next
312
+
313
+ if (trackRef.current) {
314
+ // Snapped only at the DOM boundary — translatePx stays float so
315
+ // wheel/drag/settle physics never accumulate rounding.
316
+ trackRef.current.style.transform = `translate3d(${snapToDevicePixel(
317
+ startInset.current - next,
318
+ )}px, 0, 0)`
319
+ }
320
+
321
+ applyLabelSticky(next)
322
+ updateFades()
323
+ },
324
+ [applyLabelSticky, updateFades],
325
+ )
326
+
327
+ applyTranslateRef.current = applyTranslate
328
+
329
+ /**
330
+ * Page mode or embedded? An explicit `mode` decides it outright.
331
+ * `"auto"` measures: the timeline is the page only when it covers most
332
+ * of the viewport *and* there is nothing behind it left to scroll.
333
+ * Both halves matter — a full-bleed hero section on a long landing page
334
+ * covers the viewport but must still hand the wheel back at the ends.
335
+ *
336
+ * Cached for MODE_RESOLVE_STALE_MS because this reads layout and the
337
+ * wheel path calls it. `force` is for measure passes, which are already
338
+ * doing layout work.
339
+ */
340
+ const resolveMode = useCallback((force = false) => {
341
+ const section = sectionRef.current
342
+ if (!section) return isEmbedRef.current
343
+
344
+ const mode = modeRef.current
345
+
346
+ if (mode !== "auto") {
347
+ const embed = mode === "embed"
348
+ if (embed !== isEmbedRef.current) {
349
+ isEmbedRef.current = embed
350
+ setIsEmbed(embed)
351
+ }
352
+ return embed
353
+ }
354
+
355
+ const now = performance.now()
356
+ if (!force && now - modeResolvedAt.current < MODE_RESOLVE_STALE_MS) {
357
+ return isEmbedRef.current
358
+ }
359
+ modeResolvedAt.current = now
360
+
361
+ const isPage =
362
+ getViewportCoverage(section) >= PAGE_MODE_VIEWPORT_COVERAGE &&
363
+ !hasReleasableScroll(section)
364
+
365
+ if (isPage === !isEmbedRef.current) return isEmbedRef.current
366
+
367
+ isEmbedRef.current = !isPage
368
+ setIsEmbed(!isPage)
369
+ return isEmbedRef.current
370
+ }, [])
371
+
372
+ const measureLayout = useCallback(() => {
373
+ const track = trackRef.current
374
+ const section = sectionRef.current
375
+ if (!track || !section) return 0
376
+
377
+ const embed = resolveMode(true)
378
+ const stageRect = section.getBoundingClientRect()
379
+
380
+ const navLogo = document.querySelector("[data-site-nav-logo]")
381
+ const navInner = document.querySelector("[data-site-nav-inner]")
382
+
383
+ const logoLeft = navLogo
384
+ ? navLogo.getBoundingClientRect().left - stageRect.left
385
+ : null
386
+ const navRight = navInner
387
+ ? navInner.getBoundingClientRect().right -
388
+ stageRect.left -
389
+ NAV_HORIZONTAL_PADDING
390
+ : null
391
+
392
+ /**
393
+ * A full-page lifeline always follows the host chrome. An embedded one
394
+ * follows it only when the module actually spans it — a full-bleed
395
+ * module lines its rail up with the logo and the container's right edge,
396
+ * exactly as the full-page version does, while a timeline in a narrow
397
+ * card has nothing to align to a nav sitting outside its own box and
398
+ * measures itself instead.
399
+ */
400
+ const followChrome =
401
+ !embed ||
402
+ (logoLeft !== null &&
403
+ navRight !== null &&
404
+ logoLeft >= 0 &&
405
+ navRight <= stageRect.width &&
406
+ navRight > logoLeft)
407
+
408
+ if (followChrome && logoLeft !== null) {
409
+ startInset.current = logoLeft
410
+ } else {
411
+ startInset.current = LIFELINE_DEFAULT_START_INSET
412
+ }
413
+
414
+ if (followChrome && navRight !== null) {
415
+ endInset.current = navRight
416
+ } else {
417
+ // No chrome to align with, or none this module spans — end the track
418
+ // at the stage's own right edge instead.
419
+ endInset.current = stageRect.width - NAV_HORIZONTAL_PADDING
420
+ }
421
+
422
+ const lastMarker = markerRefs.current[markerCount - 1]
423
+ const lastMarkerRight = lastMarker
424
+ ? LIFELINE_STICKY_SHIELD_WIDTH +
425
+ lastMarker.offsetLeft +
426
+ lastMarker.offsetWidth
427
+ : track.scrollWidth
428
+
429
+ const max = Math.max(
430
+ 0,
431
+ startInset.current + lastMarkerRight - endInset.current,
432
+ )
433
+ maxTranslate.current = max
434
+
435
+ return max
436
+ }, [markerCount, resolveMode])
437
+
438
+ useLayoutEffect(() => {
439
+ markerRefs.current.length = markerCount
440
+ }, [markerCount])
441
+
442
+ useLayoutEffect(() => {
443
+ // A timeline short enough to fit its stage measures max = 0. It still has
444
+ // to be shown — gating readiness on a scrollable track left any small
445
+ // timeline permanently `invisible`.
446
+ const max = measureLayout()
447
+
448
+ if (!initialized.current) {
449
+ // A skipped intro parks the rail where the intro would have settled
450
+ // it — its end, the present. Embedded is no different: it is the same
451
+ // intro and the same resting place.
452
+ translatePx.current = introSkippedRef.current ? max : 0
453
+ initialized.current = true
454
+ }
455
+
456
+ applyTranslate(translatePx.current)
457
+ setIsLayoutReady(true)
458
+ // Sync initial position once before first paint; resize uses measure().
459
+ }, [])
460
+
461
+ /**
462
+ * A full-page lifeline opens as the page opens, so its intro needs no
463
+ * cue. An embedded one can be anywhere, including far below the fold —
464
+ * playing there would spend the sweep on nobody. So it waits until the
465
+ * module is about to come into view.
466
+ */
467
+ useEffect(() => {
468
+ if (!isEmbed) return
469
+
470
+ const section = sectionRef.current
471
+ if (!section || introArmed) return
472
+
473
+ const observer = new IntersectionObserver(
474
+ (entries) => {
475
+ if (!entries.some((entry) => entry.isIntersecting)) return
476
+ setIntroArmed(true)
477
+ observer.disconnect()
478
+ },
479
+ { rootMargin: EMBED_INTRO_ARM_MARGIN },
480
+ )
481
+
482
+ observer.observe(section)
483
+ return () => observer.disconnect()
484
+ }, [isEmbed, introArmed])
485
+
486
+ useEffect(() => {
487
+ if (!isLayoutReady) return
488
+ // Embedded, hold the sweep until the module is in view. `isLayoutReady`
489
+ // and `isEmbed` are set in the same commit, so by the time this runs for
490
+ // real the mode is already known and an embedded instance cannot slip
491
+ // through and start early.
492
+ if (isEmbed && !introArmed) return
493
+ if (options.introSkipped || !options.introAnimating) {
494
+ cancelAnimationFrame(introScrollId.current)
495
+ introScrollId.current = 0
496
+ introStartedRef.current = false
497
+ return
498
+ }
499
+
500
+ introWasAnimatingRef.current = true
501
+ const railMs = options.introRailMs ?? 3200
502
+
503
+ const step = (now: number) => {
504
+ const max = maxTranslate.current
505
+
506
+ if (max <= 0) {
507
+ // Nothing to travel: a timeline that fits its stage has no rail to
508
+ // sweep. Waiting for one spun this loop forever and left the intro
509
+ // lock on, so run the intro out where it already is — the markers
510
+ // and labels still get their fade, there is just no journey.
511
+ if (!introStartedRef.current) {
512
+ introStartedRef.current = true
513
+ introScrollStart.current = now
514
+ onIntroScrollStartRef.current?.()
515
+ }
516
+ sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
517
+ introScrollId.current = 0
518
+ return
519
+ }
520
+
521
+ if (!introStartedRef.current) {
522
+ introStartedRef.current = true
523
+ introScrollStart.current = now
524
+ onIntroScrollStartRef.current?.()
525
+ sectionRef.current?.style.setProperty("--lifeline-intro-progress", "0")
526
+ applyTranslateRef.current(0)
527
+ }
528
+
529
+ const elapsed = now - introScrollStart.current
530
+ const progress = introGetTrackProgressRef.current
531
+ ? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)
532
+ : clamp(elapsed / railMs, 0, 1)
533
+
534
+ sectionRef.current?.style.setProperty(
535
+ "--lifeline-intro-progress",
536
+ String(progress),
537
+ )
538
+ applyTranslateRef.current(progress * max)
539
+
540
+ if (progress < 1) {
541
+ introScrollId.current = requestAnimationFrame(step)
542
+ return
543
+ }
544
+
545
+ sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
546
+ applyTranslateRef.current(max)
547
+ introScrollId.current = 0
548
+ }
549
+
550
+ introScrollId.current = requestAnimationFrame(step)
551
+
552
+ return () => {
553
+ cancelAnimationFrame(introScrollId.current)
554
+ introScrollId.current = 0
555
+ introStartedRef.current = false
556
+ }
557
+ }, [
558
+ introArmed,
559
+ isEmbed,
560
+ isLayoutReady,
561
+ options.introAnimating,
562
+ options.introRailMs,
563
+ options.introSkipped,
564
+ ])
565
+
566
+ useEffect(() => {
567
+ if (options.introSkipped) return
568
+
569
+ if (options.introAnimating) return
570
+
571
+ if (!introWasAnimatingRef.current) return
572
+ introWasAnimatingRef.current = false
573
+
574
+ sectionRef.current?.style.removeProperty("--lifeline-intro-progress")
575
+ markerRefs.current.forEach((marker) => {
576
+ if (marker) marker.style.opacity = ""
577
+ })
578
+ updateFades()
579
+ onIntroSettleCompleteRef.current?.()
580
+ }, [
581
+ options.introAnimating,
582
+ options.introSkipped,
583
+ updateFades,
584
+ ])
585
+
586
+ useEffect(() => {
587
+ return () => {
588
+ initialized.current = false
589
+ }
590
+ }, [])
591
+
592
+ useEffect(() => {
593
+ const section = sectionRef.current
594
+ if (!section) return
595
+
596
+ let frameId = 0
597
+ let resizeObserver: ResizeObserver | null = null
598
+
599
+ const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
600
+ const onMotionChange = () => {
601
+ prefersReducedMotionRef.current = motionQuery.matches
602
+ }
603
+ prefersReducedMotionRef.current = motionQuery.matches
604
+ motionQuery.addEventListener("change", onMotionChange)
605
+
606
+ const stopMomentum = () => {
607
+ cancelAnimationFrame(momentumId.current)
608
+ momentumId.current = 0
609
+ }
610
+
611
+ const startMomentum = () => {
612
+ if (Math.abs(dragVelocity.current) < MOMENTUM_MIN_START) return
613
+
614
+ stopMomentum()
615
+ let lastFrameTime = performance.now()
616
+
617
+ const step = (now: number) => {
618
+ const dt = Math.min(now - lastFrameTime, 32)
619
+ lastFrameTime = now
620
+
621
+ const velocity = dragVelocity.current
622
+ if (Math.abs(velocity) < MOMENTUM_MIN_VELOCITY) {
623
+ dragVelocity.current = 0
624
+ momentumId.current = 0
625
+ return
626
+ }
627
+
628
+ const max = maxTranslate.current
629
+ const next = clamp(translatePx.current + velocity * dt, 0, max)
630
+
631
+ if (next !== translatePx.current) {
632
+ applyTranslate(next)
633
+ }
634
+
635
+ if (next <= 0 || next >= max) {
636
+ // Coasting into an end counts as hitting it, so the next wheel
637
+ // event starts its hold from the moment of contact rather than
638
+ // restarting the clock.
639
+ if (isEmbedRef.current && boundaryHitAt.current === 0) {
640
+ boundaryHitAt.current = performance.now()
641
+ }
642
+ dragVelocity.current = 0
643
+ momentumId.current = 0
644
+ return
645
+ }
646
+
647
+ dragVelocity.current =
648
+ velocity * Math.pow(MOMENTUM_FRICTION, dt / 16.67)
649
+ momentumId.current = requestAnimationFrame(step)
650
+ }
651
+
652
+ momentumId.current = requestAnimationFrame(step)
653
+ }
654
+
655
+ const measure = () => {
656
+ // max === 0 is a legitimate measurement — a timeline that fits — so it
657
+ // must not skip the ready flag either.
658
+ const max = measureLayout()
659
+
660
+ translatePx.current = clamp(translatePx.current, 0, max)
661
+
662
+ // During intro scroll, the rAF loop owns translate — only refresh bounds.
663
+ if (!(introAnimatingRef.current && introStartedRef.current)) {
664
+ applyTranslate(translatePx.current)
665
+ }
666
+
667
+ setIsLayoutReady(true)
668
+ }
669
+
670
+ const scheduleMeasure = () => {
671
+ cancelAnimationFrame(frameId)
672
+ frameId = requestAnimationFrame(measure)
673
+ }
674
+
675
+ scheduleMeasureRef.current = scheduleMeasure
676
+
677
+ scheduleMeasure()
678
+ frameId = requestAnimationFrame(() => {
679
+ measure()
680
+ requestAnimationFrame(measure)
681
+ })
682
+
683
+ resizeObserver = new ResizeObserver(scheduleMeasure)
684
+ if (trackRef.current) resizeObserver.observe(trackRef.current)
685
+
686
+ window.addEventListener("resize", scheduleMeasure)
687
+
688
+ const scrub = (movement: number, target: number) => {
689
+ applyTranslate(target)
690
+
691
+ const impulse = (movement / WHEEL_VELOCITY_FRAME_MS) * 0.35
692
+ dragVelocity.current =
693
+ dragVelocity.current * (1 - WHEEL_MOMENTUM_BLEND) +
694
+ impulse * WHEEL_MOMENTUM_BLEND
695
+
696
+ // Embedded under reduced motion, skip the coast: inertia is what the
697
+ // preference asks you to drop, and it is also the one thing that
698
+ // makes the release decision non-deterministic.
699
+ if (isEmbedRef.current && prefersReducedMotionRef.current) return
700
+
701
+ if (momentumId.current === 0) {
702
+ startMomentum()
703
+ }
704
+ }
705
+
706
+ const release = () => {
707
+ gestureReleased.current = true
708
+ stopMomentum()
709
+ dragVelocity.current = 0
710
+ }
711
+
712
+ /**
713
+ * Segments the wheel stream into gestures, before the section's own
714
+ * handler sees the event — a capture listener on the window runs ahead
715
+ * of the target. The section alone cannot tell a gesture that started
716
+ * on it from a page-scroll gesture that merely arrived on it, because
717
+ * it only sees events once it is under the pointer.
718
+ */
719
+ const onWheelStream = (event: WheelEvent) => {
720
+ const now = performance.now()
721
+ wheelGapMs.current = now - wheelStreamAt.current
722
+ wheelStreamAt.current = now
723
+
724
+ if (wheelGapMs.current <= WHEEL_GESTURE_QUIET_MS) return
725
+
726
+ gestureStartedHere.current =
727
+ event.target instanceof Node && section.contains(event.target)
728
+ gestureReleased.current = false
729
+ boundaryHitAt.current = 0
730
+ }
731
+
732
+ const onWheel = (event: WheelEvent) => {
733
+ if (isScrollLocked()) return
734
+
735
+ if (maxTranslate.current <= 0) {
736
+ scheduleMeasure()
737
+ return
738
+ }
739
+
740
+ // A sideways swipe is never a request to scroll the page, so it
741
+ // scrubs at the rail ends too and never releases.
742
+ const horizontalIntent = Math.abs(event.deltaX) > Math.abs(event.deltaY)
743
+
744
+ const delta = normalizeWheelDelta(event)
745
+ /**
746
+ * The two axes are not the same gesture and do not share a sign.
747
+ *
748
+ * Vertical is page scrolling: the intro leaves the rail at the
749
+ * present, and scrolling down walks back through it — the same in both
750
+ * modes. Embedding changes where the wheel *goes* at the ends of the
751
+ * rail, not which way the rail travels.
752
+ *
753
+ * Horizontal is a drag by another name, so it has to match the pointer
754
+ * drag below: the rail follows the fingers. Swiping left (deltaX > 0)
755
+ * carries the timeline left, into the future, exactly as grabbing it
756
+ * and pulling left does.
757
+ */
758
+ const movement = (horizontalIntent ? delta : -delta) * WHEEL_SPEED
759
+
760
+ if (!resolveMode()) {
761
+ // Page mode: the lifeline is the page and every wheel is ours.
762
+ event.preventDefault()
763
+ scrub(movement, translatePx.current + movement)
764
+ return
765
+ }
766
+
767
+ if (!horizontalIntent) {
768
+ // A gesture already in flight when it reached us belongs to the
769
+ // page: the flick carries past, and the next scroll scrubs.
770
+ if (!gestureStartedHere.current) return
771
+ if (gestureReleased.current) return
772
+ }
773
+
774
+ const target = clamp(
775
+ translatePx.current + movement,
776
+ 0,
777
+ maxTranslate.current,
778
+ )
779
+
780
+ if (target === translatePx.current && !horizontalIntent) {
781
+ // At an end of the rail, still being pushed further out. Hold the
782
+ // wheel until the stream goes quiet, then hand it to the page.
783
+ const now = performance.now()
784
+ if (boundaryHitAt.current === 0) boundaryHitAt.current = now
785
+
786
+ if (
787
+ wheelGapMs.current < EMBED_BOUNDARY_QUIET_MS &&
788
+ now - boundaryHitAt.current < EMBED_BOUNDARY_MAX_HOLD_MS
789
+ ) {
790
+ event.preventDefault()
791
+ stopMomentum()
792
+ dragVelocity.current = 0
793
+ return
794
+ }
795
+
796
+ release()
797
+ return
798
+ }
799
+
800
+ // Moving again — a reversal re-arms the hold at the other end.
801
+ boundaryHitAt.current = 0
802
+ event.preventDefault()
803
+ scrub(movement, target)
804
+ }
805
+
806
+ // The rail moves by transform; any native scroll on the section is the
807
+ // browser chasing a focused link deep in the track, and would leave the
808
+ // transform and the layout disagreeing about where the rail is.
809
+ const onSectionScroll = () => {
810
+ if (section.scrollLeft !== 0) section.scrollLeft = 0
811
+ if (section.scrollTop !== 0) section.scrollTop = 0
812
+ }
813
+
814
+ const beginDrag = (event: PointerEvent) => {
815
+ stopMomentum()
816
+ dragVelocity.current = 0
817
+ dragging.current = true
818
+ activePointerId.current = event.pointerId
819
+ dragOrigin.current = { x: event.clientX, translate: translatePx.current }
820
+ lastPointerSample.current = {
821
+ x: event.clientX,
822
+ t: performance.now(),
823
+ }
824
+
825
+ if (section.setPointerCapture) {
826
+ section.setPointerCapture(event.pointerId)
827
+ }
828
+
829
+ section.style.cursor = "grabbing"
830
+ section.style.touchAction = "none"
831
+ }
832
+
833
+ const onPointerDown = (event: PointerEvent) => {
834
+ if (isScrollLocked()) return
835
+ if (isInteractiveTarget(event.target)) return
836
+ if (maxTranslate.current <= 0) return
837
+ if (activePointerId.current !== null) return
838
+
839
+ gestureAxis.current = null
840
+ gestureStart.current = { x: event.clientX, y: event.clientY }
841
+
842
+ if (event.pointerType === "touch") {
843
+ activePointerId.current = event.pointerId
844
+ return
845
+ }
846
+
847
+ beginDrag(event)
848
+ }
849
+
850
+ const onPointerMove = (event: PointerEvent) => {
851
+ if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
852
+ return
853
+ }
854
+
855
+ if (!dragging.current && event.pointerType === "touch") {
856
+ const deltaX = event.clientX - gestureStart.current.x
857
+ const deltaY = event.clientY - gestureStart.current.y
858
+
859
+ if (gestureAxis.current === null) {
860
+ if (
861
+ Math.abs(deltaX) < TOUCH_GESTURE_LOCK_PX &&
862
+ Math.abs(deltaY) < TOUCH_GESTURE_LOCK_PX
863
+ ) {
864
+ return
865
+ }
866
+
867
+ gestureAxis.current =
868
+ Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y"
869
+
870
+ if (gestureAxis.current === "y") {
871
+ activePointerId.current = null
872
+ return
873
+ }
874
+
875
+ beginDrag(event)
876
+ }
877
+ }
878
+
879
+ if (!dragging.current) return
880
+
881
+ if (event.pointerType === "touch") {
882
+ event.preventDefault()
883
+ }
884
+
885
+ const now = performance.now()
886
+ const sample = lastPointerSample.current
887
+ const elapsed = now - sample.t
888
+ const dragSpeed = event.pointerType === "touch" ? TOUCH_DRAG_SPEED : DRAG_SPEED
889
+
890
+ if (elapsed > 0 && elapsed < 80) {
891
+ const instantVelocity =
892
+ (-(event.clientX - sample.x) / elapsed) * dragSpeed
893
+ dragVelocity.current =
894
+ instantVelocity * 0.65 + dragVelocity.current * 0.35
895
+ }
896
+
897
+ lastPointerSample.current = { x: event.clientX, t: now }
898
+
899
+ const deltaX = event.clientX - dragOrigin.current.x
900
+ applyTranslate(dragOrigin.current.translate - deltaX * dragSpeed)
901
+ }
902
+
903
+ const endDrag = (event: PointerEvent) => {
904
+ if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
905
+ return
906
+ }
907
+
908
+ const wasDragging = dragging.current
909
+
910
+ dragging.current = false
911
+ gestureAxis.current = null
912
+ activePointerId.current = null
913
+
914
+ if (section.hasPointerCapture(event.pointerId)) {
915
+ section.releasePointerCapture(event.pointerId)
916
+ }
917
+
918
+ section.style.cursor = ""
919
+ section.style.touchAction = ""
920
+
921
+ if (wasDragging) {
922
+ startMomentum()
923
+ }
924
+ }
925
+
926
+ // Arrow keys scrub only when the lifeline effectively is the page, or
927
+ // when focus is inside it. An embedded instance must not capture host
928
+ // keyboard scrolling until the reader has tabbed into it.
929
+ const ownsKeyboard = () => {
930
+ const active = document.activeElement
931
+ if (active && section.contains(active)) return true
932
+
933
+ return !resolveMode()
934
+ }
935
+
936
+ const onKeyDown = (event: KeyboardEvent) => {
937
+ if (maxTranslate.current <= 0) return
938
+ if (isScrollLocked()) return
939
+ if (isEditableTarget(event.target)) return
940
+ if (!ownsKeyboard()) return
941
+
942
+ stopMomentum()
943
+ dragVelocity.current = 0
944
+
945
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
946
+ event.preventDefault()
947
+ applyTranslate(translatePx.current - maxTranslate.current * 0.05)
948
+ }
949
+
950
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
951
+ event.preventDefault()
952
+ applyTranslate(translatePx.current + maxTranslate.current * 0.05)
953
+ }
954
+ }
955
+
956
+ window.addEventListener("wheel", onWheelStream, {
957
+ passive: true,
958
+ capture: true,
959
+ })
960
+ section.addEventListener("scroll", onSectionScroll, { passive: true })
961
+ section.addEventListener("wheel", onWheel, { passive: false })
962
+ section.addEventListener("pointerdown", onPointerDown)
963
+ section.addEventListener("pointermove", onPointerMove, { passive: false })
964
+ section.addEventListener("pointerup", endDrag)
965
+ section.addEventListener("pointercancel", endDrag)
966
+ window.addEventListener("keydown", onKeyDown)
967
+
968
+ return () => {
969
+ cancelAnimationFrame(frameId)
970
+ stopMomentum()
971
+ cancelAnimationFrame(settleId.current)
972
+ settlingRef.current = false
973
+ resizeObserver?.disconnect()
974
+ motionQuery.removeEventListener("change", onMotionChange)
975
+ window.removeEventListener("resize", scheduleMeasure)
976
+ window.removeEventListener("wheel", onWheelStream, { capture: true })
977
+ section.removeEventListener("scroll", onSectionScroll)
978
+ section.removeEventListener("wheel", onWheel)
979
+ section.removeEventListener("pointerdown", onPointerDown)
980
+ section.removeEventListener("pointermove", onPointerMove)
981
+ section.removeEventListener("pointerup", endDrag)
982
+ section.removeEventListener("pointercancel", endDrag)
983
+ window.removeEventListener("keydown", onKeyDown)
984
+ dragging.current = false
985
+ gestureAxis.current = null
986
+ activePointerId.current = null
987
+ // A remount must not start out believing it is mid-release.
988
+ gestureStartedHere.current = false
989
+ gestureReleased.current = false
990
+ boundaryHitAt.current = 0
991
+ wheelGapMs.current = Number.POSITIVE_INFINITY
992
+ section.style.cursor = ""
993
+ section.style.touchAction = ""
994
+ }
995
+ }, [applyTranslate, isScrollLocked, markerCount, measureLayout, resolveMode])
996
+
997
+ return {
998
+ sectionRef,
999
+ trackRef,
1000
+ labelsRef,
1001
+ setMarkerRef,
1002
+ isLayoutReady,
1003
+ isEmbed,
1004
+ /**
1005
+ * Embedded only: whether the module has come into view and the intro is
1006
+ * cleared to play. Page mode never waits, so this stays false there and
1007
+ * callers should read it as `!isEmbed || introArmed`.
1008
+ */
1009
+ introArmed,
1010
+ }
1011
+ }