@boyernick/standard-ui-react 0.1.1-canary.8 → 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 (83) 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 +41 -13
  17. package/src/date-picker.tsx +7 -2
  18. package/src/dialog.tsx +33 -10
  19. package/src/empty.tsx +4 -4
  20. package/src/field.tsx +4 -3
  21. package/src/filter-group.tsx +143 -0
  22. package/src/icons.tsx +28 -1
  23. package/src/illustrations.tsx +219 -141
  24. package/src/index.ts +245 -18
  25. package/src/input.tsx +8 -5
  26. package/src/kbd.tsx +49 -0
  27. package/src/lib/focus.ts +41 -0
  28. package/src/lib/popup.ts +1 -1
  29. package/src/lifeline/company-icon.tsx +71 -0
  30. package/src/lifeline/index.ts +42 -0
  31. package/src/lifeline/lifeline-data.ts +97 -0
  32. package/src/lifeline/lifeline-desktop.tsx +204 -0
  33. package/src/lifeline/lifeline-event.tsx +108 -0
  34. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  35. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  36. package/src/lifeline/lifeline-icons.tsx +39 -0
  37. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  38. package/src/lifeline/lifeline-labels.tsx +24 -0
  39. package/src/lifeline/lifeline-layout.ts +1 -0
  40. package/src/lifeline/lifeline-legend.tsx +31 -0
  41. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  42. package/src/lifeline/lifeline-marker.tsx +183 -0
  43. package/src/lifeline/lifeline-people.tsx +99 -0
  44. package/src/lifeline/lifeline-photos.tsx +366 -0
  45. package/src/lifeline/lifeline-shell.tsx +135 -0
  46. package/src/lifeline/lifeline-utils.ts +83 -0
  47. package/src/lifeline/lifeline-vertical.tsx +482 -0
  48. package/src/lifeline/lifeline.tsx +69 -0
  49. package/src/lifeline/types.ts +112 -0
  50. package/src/lifeline/use-lifeline-intro.ts +130 -0
  51. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  52. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  53. package/src/menubar.tsx +9 -2
  54. package/src/minimap.tsx +296 -0
  55. package/src/modal.tsx +608 -0
  56. package/src/navigation-menu.tsx +338 -67
  57. package/src/number-field.tsx +336 -61
  58. package/src/otp-field.tsx +4 -3
  59. package/src/pagination.tsx +262 -42
  60. package/src/password-protection.tsx +345 -0
  61. package/src/popover.tsx +1 -1
  62. package/src/progress.tsx +5 -0
  63. package/src/questionnaire.tsx +284 -0
  64. package/src/radio.tsx +4 -2
  65. package/src/scroll-area.tsx +4 -1
  66. package/src/select.tsx +5 -2
  67. package/src/sidebar.tsx +113 -28
  68. package/src/slider.tsx +66 -6
  69. package/src/sounds.tsx +6 -10
  70. package/src/spinner.tsx +105 -32
  71. package/src/switch.tsx +4 -1
  72. package/src/table.tsx +82 -15
  73. package/src/tabs.tsx +189 -38
  74. package/src/text-animate.tsx +71 -28
  75. package/src/textarea.tsx +6 -1
  76. package/src/timeline.tsx +378 -0
  77. package/src/toast.tsx +214 -35
  78. package/src/toggle.tsx +4 -2
  79. package/src/toolbar.tsx +12 -7
  80. package/src/tooltip.tsx +43 -3
  81. package/src/video-player.tsx +396 -127
  82. package/src/image-modal.tsx +0 -110
  83. package/src/markdown-editor.tsx +0 -302
@@ -0,0 +1,482 @@
1
+ "use client"
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type CSSProperties,
11
+ } from "react"
12
+ import { LifelineFilmIcon as Film, LifelineImageIcon as ImageIcon } from "./lifeline-icons"
13
+ import { cn } from "../lib/cn"
14
+ import { CompanyIcon } from "./company-icon"
15
+ import {
16
+ getLifelineEventEffect,
17
+ getLifelineEventImage,
18
+ getLifelineEventKey,
19
+ LifelineEventText,
20
+ } from "./lifeline-event"
21
+ import { useLifelineFireworks } from "./lifeline-fireworks"
22
+ import {
23
+ LifelineLightbox,
24
+ type LifelineLightboxStart,
25
+ } from "./lifeline-lightbox"
26
+ import { aggregateLifelinePeople, LifelinePeople } from "./lifeline-people"
27
+ import { LifelinePhotoCard } from "./lifeline-photos"
28
+ import type { LifelineEvent, LifelineMarker, LifelineProps } from "./types"
29
+ import { getMarkerHeight, hasMarkerContent, seededUnit } from "./lifeline-utils"
30
+ import { useLifelineIntro } from "./use-lifeline-intro"
31
+ import { useLifelineVerticalScroll } from "./use-lifeline-vertical-scroll"
32
+
33
+ const GRID_CLASS = "grid grid-cols-[2.5rem_1rem_1fr] gap-x-3"
34
+ const RAIL_LEFT = "calc(2.5rem + 0.75rem + 0.5rem)"
35
+
36
+ /**
37
+ * Above this many entries the delay-armed intro fades would promote
38
+ * every entry to a compositor layer at once and crash mobile Safari's
39
+ * compositor. Long timelines fade entries in as they enter the
40
+ * viewport during the auto-scroll instead — same look, but only a
41
+ * handful of live animations at any moment.
42
+ */
43
+ const MAX_ARMED_ENTRIES = 80
44
+
45
+ function RailTick() {
46
+ return (
47
+ <span
48
+ aria-hidden="true"
49
+ className="block h-px w-[10px] bg-fg-quaternary transition-colors duration-300"
50
+ />
51
+ )
52
+ }
53
+
54
+ /**
55
+ * One event line. Touch layouts have no hover reveal, so an event with
56
+ * attached media becomes tappable: the media expands into the lightbox
57
+ * from the event's text, framed by the poster image's real aspect.
58
+ */
59
+ function LifelineVerticalEvent({ event }: { event: LifelineEvent }) {
60
+ const fireworks = useLifelineFireworks()
61
+ const image = getLifelineEventImage(event)
62
+ const effect = getLifelineEventEffect(event)
63
+ const textRef = useRef<HTMLParagraphElement>(null)
64
+ const aspectRef = useRef(3 / 4)
65
+ const [lightboxStart, setLightboxStart] =
66
+ useState<LifelineLightboxStart | null>(null)
67
+
68
+ // The event text has no card geometry — synthesize a small seed
69
+ // centered on the text, carrying the media's aspect so the lightbox
70
+ // expands into the right frame.
71
+ const measureText = useCallback((): LifelineLightboxStart | null => {
72
+ const el = textRef.current
73
+ if (!el) return null
74
+ const rect = el.getBoundingClientRect()
75
+ const w = 96
76
+ return {
77
+ cx: rect.left + rect.width / 2,
78
+ cy: rect.top + rect.height / 2,
79
+ w,
80
+ h: w * aspectRef.current,
81
+ }
82
+ }, [])
83
+
84
+ const openMedia = () => {
85
+ if (!image || lightboxStart) return
86
+ // The poster sets the frame; for videos it shares the clip's aspect.
87
+ const probe = new window.Image()
88
+ probe.src = image.src
89
+ const open = () => {
90
+ if (probe.naturalWidth > 0) {
91
+ aspectRef.current = probe.naturalHeight / probe.naturalWidth
92
+ }
93
+ setLightboxStart(measureText())
94
+ }
95
+ if (probe.complete) {
96
+ open()
97
+ } else {
98
+ probe.onload = open
99
+ probe.onerror = open
100
+ }
101
+ }
102
+
103
+ return (
104
+ <>
105
+ <p
106
+ ref={textRef}
107
+ className={cn(
108
+ "max-w-[18rem] text-left text-[14px] leading-[1.55] tracking-[-0.01em]",
109
+ (image || effect) && "cursor-pointer",
110
+ )}
111
+ onClick={
112
+ image
113
+ ? openMedia
114
+ : effect && fireworks
115
+ ? () => fireworks.launch(effect)
116
+ : undefined
117
+ }
118
+ >
119
+ <LifelineEventText event={event} />
120
+ {image && (
121
+ // Glued to the last word with a no-break space so the icon
122
+ // can never wrap onto a line of its own.
123
+ <span className="whitespace-nowrap">
124
+ {" "}
125
+ {image.video ? (
126
+ <Film
127
+ className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
128
+ strokeWidth={1.75}
129
+ aria-hidden="true"
130
+ />
131
+ ) : (
132
+ <ImageIcon
133
+ className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
134
+ strokeWidth={1.75}
135
+ aria-hidden="true"
136
+ />
137
+ )}
138
+ </span>
139
+ )}
140
+ </p>
141
+ {lightboxStart && image && (
142
+ <LifelineLightbox
143
+ photo={image}
144
+ rotate={0}
145
+ start={lightboxStart}
146
+ getHome={measureText}
147
+ onClosed={() => setLightboxStart(null)}
148
+ />
149
+ )}
150
+ </>
151
+ )
152
+ }
153
+
154
+ const LifelineVerticalEntry = forwardRef<
155
+ HTMLLIElement,
156
+ {
157
+ marker: LifelineMarker
158
+ birthYear: number
159
+ animateIntro?: boolean
160
+ introDelay?: number
161
+ introDuration?: number
162
+ revealPending?: boolean
163
+ }
164
+ >(function LifelineVerticalEntry(
165
+ {
166
+ marker,
167
+ birthYear,
168
+ animateIntro = false,
169
+ introDelay = 0,
170
+ introDuration = 420,
171
+ revealPending = false,
172
+ },
173
+ ref,
174
+ ) {
175
+ const age = marker.age ?? marker.year - birthYear
176
+ const people = aggregateLifelinePeople(marker)
177
+ const photos = marker.photos ?? []
178
+ const hasContent = hasMarkerContent(marker) || photos.length > 0
179
+ // Fresh tilts per visit; stacked neighbors lean apart.
180
+ const [photoTilts] = useState(() =>
181
+ (marker.photos ?? []).map((photo, index) => {
182
+ const unit = seededUnit(`${photo.src}:${index}`)
183
+ const sign =
184
+ (marker.photos?.length ?? 0) > 1
185
+ ? index % 2 === 0
186
+ ? -1
187
+ : 1
188
+ : unit > 0.5
189
+ ? 1
190
+ : -1
191
+ return sign * (2 + seededUnit(`${photo.src}:${index}:tilt`) * 4)
192
+ }),
193
+ )
194
+
195
+ return (
196
+ <li
197
+ ref={ref}
198
+ className={hasContent ? "pb-10" : "pb-3"}
199
+ aria-label={marker.label ?? `${marker.year}`}
200
+ >
201
+ <div
202
+ className={cn(
203
+ animateIntro && "lifeline-marker-intro",
204
+ revealPending && "opacity-0",
205
+ )}
206
+ style={{
207
+ animationDelay: animateIntro ? `${introDelay}ms` : undefined,
208
+ ...(animateIntro
209
+ ? ({
210
+ "--lifeline-marker-fade-ms": `${introDuration}ms`,
211
+ } as CSSProperties)
212
+ : {}),
213
+ }}
214
+ >
215
+ <div className={`${GRID_CLASS} items-center`}>
216
+ <p className="text-right text-[11px] font-medium leading-4 tabular-nums text-fg-tertiary transition-colors duration-300">
217
+ {age}
218
+ </p>
219
+
220
+ <div className="flex items-center justify-center">
221
+ <RailTick />
222
+ </div>
223
+
224
+ <p className="whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-fg-secondary transition-colors duration-300">
225
+ {marker.label ?? marker.year}
226
+ </p>
227
+ </div>
228
+
229
+ {hasContent && (
230
+ <div className={`${GRID_CLASS} mt-6`}>
231
+ <div aria-hidden="true" />
232
+ <div aria-hidden="true" />
233
+ <div className="min-w-0 text-fg-secondary transition-colors duration-300">
234
+ {marker.badges && marker.badges.length > 0 && (
235
+ <div className="mb-3 flex items-center justify-start gap-2">
236
+ {marker.badges.map((badge) => (
237
+ // eslint-disable-next-line @next/next/no-img-element
238
+ <img
239
+ key={badge.src}
240
+ src={badge.src}
241
+ alt={badge.alt}
242
+ className="h-6 w-6 object-contain opacity-80"
243
+ />
244
+ ))}
245
+ </div>
246
+ )}
247
+
248
+ {marker.companies && marker.companies.length > 0 && (
249
+ <div className="mb-2 flex items-center justify-start gap-1.5">
250
+ {marker.companies.map((company) => (
251
+ <CompanyIcon
252
+ key={company.id}
253
+ id={company.id}
254
+ label={company.name}
255
+ className="opacity-70"
256
+ />
257
+ ))}
258
+ </div>
259
+ )}
260
+
261
+ {marker.events.length > 0 && (
262
+ <div className="space-y-4">
263
+ {marker.events.map((event, index) => (
264
+ <LifelineVerticalEvent
265
+ key={getLifelineEventKey(event, index)}
266
+ event={event}
267
+ />
268
+ ))}
269
+ </div>
270
+ )}
271
+
272
+ {photos.length > 0 && (
273
+ <div className="mt-6 flex flex-wrap items-start">
274
+ {photos.map((photo, index) => (
275
+ <LifelinePhotoCard
276
+ key={`${photo.src}-${index}`}
277
+ photo={photo}
278
+ rotate={photo.rotate ?? photoTilts[index] ?? 0}
279
+ width={160}
280
+ className={cn(
281
+ "relative",
282
+ index > 0 && "-ml-8 mt-6",
283
+ )}
284
+ />
285
+ ))}
286
+ </div>
287
+ )}
288
+
289
+ {people.length > 0 && (
290
+ <div className="mt-6 border-t border-border-secondary pt-5 transition-colors duration-300">
291
+ <LifelinePeople people={people} allowWrap />
292
+ </div>
293
+ )}
294
+ </div>
295
+ </div>
296
+ )}
297
+ </div>
298
+ </li>
299
+ )
300
+ })
301
+
302
+ export function LifelineVertical({
303
+ markers,
304
+ birthYear,
305
+ title = "Lifeline",
306
+ mode = "auto",
307
+ }: LifelineProps) {
308
+ // Only an explicit `mode` embeds the vertical layout. `"auto"` measures
309
+ // scrollability on desktop, but the mobile layout *is* a vertical
310
+ // scroller inside a scrolling stage, so that test would read every
311
+ // full-page timeline as embedded and drop its intro.
312
+ const isEmbed = mode === "embed"
313
+ const heights = useMemo(
314
+ () =>
315
+ markers.map((marker, index) =>
316
+ getMarkerHeight(marker, markers[index + 1]?.year),
317
+ ),
318
+ [markers],
319
+ )
320
+
321
+ const intro = useLifelineIntro(heights)
322
+ const isIntroAnimating = intro.shouldPlay && intro.isPlaying
323
+
324
+ // Warm the event media posters during idle — the tap-to-open
325
+ // lightbox measures its frame from these, and a cold fetch at tap
326
+ // time reads as lag.
327
+ useEffect(() => {
328
+ const sources: string[] = []
329
+ for (const marker of markers) {
330
+ for (const event of marker.events) {
331
+ const image = getLifelineEventImage(event)
332
+ if (image) sources.push(image.src)
333
+ }
334
+ }
335
+ if (sources.length === 0) return
336
+
337
+ const warm = () => {
338
+ sources.forEach((src) => {
339
+ const image = new window.Image()
340
+ image.src = src
341
+ })
342
+ }
343
+
344
+ if (typeof window.requestIdleCallback === "function") {
345
+ const handle = window.requestIdleCallback(warm)
346
+ return () => window.cancelIdleCallback(handle)
347
+ }
348
+ const timeout = window.setTimeout(warm, 2000)
349
+ return () => window.clearTimeout(timeout)
350
+ }, [markers])
351
+
352
+ const { sectionRef, setEntryRef, isLayoutReady } = useLifelineVerticalScroll(
353
+ markers.length,
354
+ {
355
+ isEmbed,
356
+ introLocked: isIntroAnimating,
357
+ introAnimating: isIntroAnimating,
358
+ // Embedded, the sweep would play out unseen below the fold — and
359
+ // lock the module's own scroller while doing it.
360
+ introSkipped: !intro.shouldPlay || isEmbed,
361
+ introRailMs: intro.railDuration,
362
+ introGetTrackProgress: intro.getTrackProgressAtTime,
363
+ onIntroScrollStart: intro.startIntroTimer,
364
+ onIntroSettleComplete: intro.completeIntro,
365
+ },
366
+ )
367
+
368
+ const showIntro = isIntroAnimating && isLayoutReady && !isEmbed
369
+ const revealOnScroll = markers.length > MAX_ARMED_ENTRIES
370
+ const animateEntries = showIntro && !revealOnScroll
371
+
372
+ // Rail-synced fades for long timelines: entries render hidden and
373
+ // each one fades in the moment the rail tip (--lifeline-intro-progress,
374
+ // written every frame by the intro scroll) crosses its position —
375
+ // desktop's choreography, but each entry drops its animation (and
376
+ // compositor layer) as soon as its fade finishes.
377
+ useEffect(() => {
378
+ if (!showIntro || !revealOnScroll) return
379
+ const section = sectionRef.current
380
+ const ol = section?.querySelector("ol")
381
+ if (!section || !ol) return
382
+
383
+ const entries = Array.from(ol.children) as HTMLElement[]
384
+ const targets = entries.map(
385
+ (li) => li.firstElementChild as HTMLElement | null,
386
+ )
387
+
388
+ const onAnimationEnd = (event: AnimationEvent) => {
389
+ if (event.animationName !== "lifeline-marker-in") return
390
+ ;(event.target as HTMLElement).classList.remove("lifeline-marker-intro")
391
+ }
392
+ section.addEventListener("animationend", onAnimationEnd)
393
+
394
+ let next = 0
395
+ let frame = 0
396
+ const tick = () => {
397
+ const progress = parseFloat(
398
+ section.style.getPropertyValue("--lifeline-intro-progress") || "0",
399
+ )
400
+ const tip = progress * ol.offsetHeight
401
+
402
+ while (next < entries.length && entries[next].offsetTop <= tip) {
403
+ const el = targets[next]
404
+ if (el) {
405
+ el.classList.remove("opacity-0")
406
+ el.classList.add("lifeline-marker-intro")
407
+ }
408
+ next++
409
+ }
410
+
411
+ if (next < entries.length) {
412
+ frame = requestAnimationFrame(tick)
413
+ }
414
+ }
415
+ frame = requestAnimationFrame(tick)
416
+
417
+ return () => {
418
+ cancelAnimationFrame(frame)
419
+ section.removeEventListener("animationend", onAnimationEnd)
420
+ targets.forEach((el) => {
421
+ el?.classList.remove("opacity-0", "lifeline-marker-intro")
422
+ })
423
+ }
424
+ }, [showIntro, revealOnScroll, sectionRef])
425
+
426
+ const introStyle = {
427
+ "--lifeline-labels-ms": `${intro.labelsDuration}ms`,
428
+ "--lifeline-rail-ms": `${intro.railDuration}ms`,
429
+ } as CSSProperties
430
+
431
+ return (
432
+ <article
433
+ ref={sectionRef}
434
+ aria-label={title}
435
+ className={cn(
436
+ "relative select-none px-6 pb-10 pt-4 [&_a]:cursor-pointer",
437
+ !isLayoutReady && "invisible",
438
+ )}
439
+ style={showIntro ? introStyle : undefined}
440
+ >
441
+ <div className={cn(`${GRID_CLASS} mb-6 items-end`, showIntro && "lifeline-labels-intro")}>
442
+ <p className="text-right text-[11px] font-medium leading-4 tracking-[0.08em] text-fg-tertiary transition-colors duration-300">
443
+ Age
444
+ </p>
445
+ <div aria-hidden="true" />
446
+ <p className="text-[11px] font-medium leading-5 tracking-[0.08em] text-fg-tertiary transition-colors duration-300">
447
+ Years
448
+ </p>
449
+ </div>
450
+
451
+ <div className="relative">
452
+ <div
453
+ aria-hidden="true"
454
+ className="pointer-events-none absolute bottom-0 top-0 overflow-hidden -translate-x-1/2"
455
+ style={{ left: RAIL_LEFT, width: 1 }}
456
+ >
457
+ <div
458
+ className={cn(
459
+ "h-full w-px border-l border-dashed border-border-primary transition-colors duration-300",
460
+ showIntro && "lifeline-rail-intro-vertical",
461
+ )}
462
+ />
463
+ </div>
464
+
465
+ <ol className="relative">
466
+ {markers.map((marker, index) => (
467
+ <LifelineVerticalEntry
468
+ key={marker.id}
469
+ ref={(node) => setEntryRef(index, node)}
470
+ marker={marker}
471
+ birthYear={birthYear}
472
+ animateIntro={animateEntries}
473
+ revealPending={showIntro && revealOnScroll}
474
+ introDelay={intro.getMarkerDelay(index)}
475
+ introDuration={intro.getMarkerFadeDuration(index)}
476
+ />
477
+ ))}
478
+ </ol>
479
+ </div>
480
+ </article>
481
+ )
482
+ }
@@ -0,0 +1,69 @@
1
+ "use client"
2
+
3
+ import { useLayoutEffect, useState } from "react"
4
+ import { cn } from "../lib/cn"
5
+ import { LifelineDesktop } from "./lifeline-desktop"
6
+ import { LifelineFireworksProvider } from "./lifeline-fireworks"
7
+ import { LifelineVertical } from "./lifeline-vertical"
8
+ import { LIFELINE_MOBILE_BREAKPOINT } from "./lifeline-layout"
9
+ import type { LifelineProps } from "./types"
10
+
11
+ /**
12
+ * `lifeline-typeset` sets `--lifeline-font` from the host sans stack.
13
+ * Override `--lifeline-font` to typeset the timeline in something else.
14
+ */
15
+ export function Lifeline(props: LifelineProps) {
16
+ const [isMobile, setIsMobile] = useState<boolean | null>(null)
17
+
18
+ useLayoutEffect(() => {
19
+ // Matches Tailwind's md: breakpoint so JS and CSS can never disagree.
20
+ const query = window.matchMedia(
21
+ `(min-width: ${LIFELINE_MOBILE_BREAKPOINT}px)`,
22
+ )
23
+ const update = () => setIsMobile(!query.matches)
24
+
25
+ update()
26
+ query.addEventListener("change", update)
27
+ return () => query.removeEventListener("change", update)
28
+ }, [])
29
+
30
+ if (isMobile === null) {
31
+ return <div className="invisible h-full" aria-hidden="true" />
32
+ }
33
+
34
+ if (isMobile) {
35
+ return (
36
+ <LifelineFireworksProvider>
37
+ {/*
38
+ Embedded, the vertical timeline gets its own bounded scroller:
39
+ the consumer's height lands here, and this element becomes the
40
+ scroll parent the vertical hook looks for. Native overscroll
41
+ chaining then releases to the page at either end, which is
42
+ exactly the embed contract. Page mode is left alone — the host's
43
+ own scroller owns it there, and `h-full` would only fight it.
44
+ */}
45
+ <div
46
+ className={
47
+ props.mode === "embed"
48
+ ? cn(
49
+ "lifeline-typeset h-full overflow-y-auto pt-5",
50
+ props.className,
51
+ )
52
+ : "lifeline-typeset pt-5"
53
+ }
54
+ >
55
+ <LifelineVertical {...props} />
56
+ </div>
57
+ </LifelineFireworksProvider>
58
+ )
59
+ }
60
+
61
+ return (
62
+ <LifelineFireworksProvider>
63
+ <LifelineDesktop
64
+ {...props}
65
+ className={cn("lifeline-typeset pt-5", props.className)}
66
+ />
67
+ </LifelineFireworksProvider>
68
+ )
69
+ }
@@ -0,0 +1,112 @@
1
+ import type { CompanyIconId } from "./company-icon"
2
+
3
+
4
+ export interface LifelineMentor {
5
+ name: string
6
+ role?: string
7
+ color?: string
8
+ photo?: string
9
+ }
10
+
11
+ export interface LifelineMetPerson {
12
+ name: string
13
+ photo?: string
14
+ }
15
+
16
+ export interface LifelineCompany {
17
+ id: CompanyIconId
18
+ name: string
19
+ }
20
+
21
+ export type LifelineEventSegment =
22
+ | { type: "text"; value: string }
23
+ | { type: "link"; value: string; href: string }
24
+
25
+ export interface LifelineEventImage {
26
+ src: string
27
+ alt: string
28
+ /** Optional mp4/webm — hover shows this (muted, looping) with src as fallback. */
29
+ video?: string
30
+ }
31
+
32
+ /**
33
+ * An always-visible photo/video card that floats over the timeline,
34
+ * anchored to its marker's position and scrolling with the track —
35
+ * scattered, tilted, and draggable, like photos loose in a notebook.
36
+ */
37
+ export interface LifelinePhoto extends LifelineEventImage {
38
+ /** 0..1 across the marker's slot; defaults to a seeded scatter. */
39
+ x?: number
40
+ /** Pixels below the rail; defaults to a seeded scatter. */
41
+ y?: number
42
+ /** Degrees; defaults to a seeded tilt. */
43
+ rotate?: number
44
+ /** Card width in pixels. Default 180. */
45
+ width?: number
46
+ }
47
+
48
+ export type LifelineEventEffect = "fireworks" | "fireworks-argentina"
49
+
50
+ /**
51
+ * Object form lets an event carry a cursor-following hover image
52
+ * and/or a click-triggered easter egg effect.
53
+ */
54
+ export interface LifelineEventObject {
55
+ text: string | LifelineEventSegment[]
56
+ image?: LifelineEventImage
57
+ effect?: LifelineEventEffect
58
+ }
59
+
60
+ export type LifelineEvent =
61
+ | string
62
+ | LifelineEventSegment[]
63
+ | LifelineEventObject
64
+
65
+ export interface LifelineMarker {
66
+ id: string
67
+ /** Position on the numeric axis — a year, or any sequential unit (e.g. tournament day). */
68
+ year: number
69
+ /** Shown above the label; defaults to year - birthYear. Strings allowed for round tags etc. */
70
+ age?: number | string
71
+ /** Shown in place of the raw year — e.g. "Jun 16" on a day-based timeline. */
72
+ label?: string
73
+ events: LifelineEvent[]
74
+ /** Small emblems (team shields etc.) rendered above the events. */
75
+ badges?: { src: string; alt: string }[]
76
+ /** Floating media cards anchored to this marker's stretch of the timeline. */
77
+ photos?: LifelinePhoto[]
78
+ companies?: LifelineCompany[]
79
+ mentors?: LifelineMentor[]
80
+ met?: LifelineMetPerson[]
81
+ }
82
+
83
+ /**
84
+ * A legend entry maps one of the two people slots to a subject-appropriate
85
+ * label — "Mentors" for a person, "Presidents" for a nation.
86
+ */
87
+ export interface LifelineLegendItem {
88
+ type: "mentor" | "met"
89
+ label: string
90
+ }
91
+
92
+ /**
93
+ * How the timeline relates to the page around it.
94
+ *
95
+ * - `"page"` — the Lifeline *is* the page. It owns the wheel and the
96
+ * arrow keys, and nothing scrolls behind it.
97
+ * - `"embed"` — the Lifeline is one module in a scrolling page. Wheel
98
+ * over it scrubs the rail, and at either end of the rail the wheel is
99
+ * handed back so the page carries on scrolling.
100
+ * - `"auto"` — measured at runtime: page mode only when the timeline
101
+ * covers most of the viewport *and* there is nothing behind it left to
102
+ * scroll. Anything else is treated as embedded.
103
+ */
104
+ export type LifelineMode = "auto" | "page" | "embed"
105
+
106
+ export interface LifelineProps {
107
+ markers: LifelineMarker[]
108
+ birthYear: number
109
+ className?: string
110
+ title?: string
111
+ mode?: LifelineMode
112
+ }