@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.
- package/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +27 -12
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +5 -2
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- package/src/markdown-editor.tsx +0 -302
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type CSSProperties, type PointerEvent } from "react"
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
import { LifelineEventMedia } from "./lifeline-event"
|
|
6
|
+
import {
|
|
7
|
+
LifelineLightbox,
|
|
8
|
+
type LifelineLightboxStart,
|
|
9
|
+
} from "./lifeline-lightbox"
|
|
10
|
+
import type { LifelineMarker, LifelinePhoto } from "./types"
|
|
11
|
+
import { seededUnit } from "./lifeline-utils"
|
|
12
|
+
|
|
13
|
+
/** Tweak these */
|
|
14
|
+
const CARD_WIDTH = 180
|
|
15
|
+
/** Matches the events column's pt-6 — cards align with the first event's text. */
|
|
16
|
+
const EVENT_TOP = 24
|
|
17
|
+
const MAX_TILT_DEG = 6
|
|
18
|
+
/**
|
|
19
|
+
* How far along a card the next one in the stack starts — 0.6 leaves
|
|
20
|
+
* a solid margin of every card visible under its neighbor.
|
|
21
|
+
*/
|
|
22
|
+
const STACK_OVERLAP = 0.6
|
|
23
|
+
/**
|
|
24
|
+
* Stacks cascade diagonally: the last card sits level with the event
|
|
25
|
+
* text and each one before it hangs this much lower, so neighbors
|
|
26
|
+
* overlap corner-to-corner instead of side-by-side.
|
|
27
|
+
*/
|
|
28
|
+
const CASCADE_Y = 170
|
|
29
|
+
/** Event text column: max-w-[18rem] plus breathing room. */
|
|
30
|
+
const TEXT_ZONE = 288 + 24
|
|
31
|
+
/** Pointer travel below this is a click (opens the lightbox), not a drag. */
|
|
32
|
+
const CLICK_SLOP = 4
|
|
33
|
+
/** Fingers wobble more than mice — touch presses get extra tap room. */
|
|
34
|
+
const TOUCH_CLICK_SLOP = 10
|
|
35
|
+
|
|
36
|
+
function randomTilt(seed: string) {
|
|
37
|
+
return 2 + seededUnit(seed) * (MAX_TILT_DEG - 2)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The interactive photo card, positioning-agnostic: drag moves it for
|
|
42
|
+
* the session, a press without travel expands it into the lightbox.
|
|
43
|
+
* Desktop floats it over the track (absolute + left/top); the vertical
|
|
44
|
+
* layout drops it into normal flow.
|
|
45
|
+
*/
|
|
46
|
+
export function LifelinePhotoCard({
|
|
47
|
+
photo,
|
|
48
|
+
rotate,
|
|
49
|
+
width,
|
|
50
|
+
className,
|
|
51
|
+
style,
|
|
52
|
+
animateIntro = false,
|
|
53
|
+
introDelay = 0,
|
|
54
|
+
introDuration = 420,
|
|
55
|
+
}: {
|
|
56
|
+
photo: LifelinePhoto
|
|
57
|
+
/** Resolved resting tilt, degrees. */
|
|
58
|
+
rotate: number
|
|
59
|
+
width: number
|
|
60
|
+
/** Positioning context from the caller (e.g. "absolute"). */
|
|
61
|
+
className?: string
|
|
62
|
+
style?: CSSProperties
|
|
63
|
+
animateIntro?: boolean
|
|
64
|
+
introDelay?: number
|
|
65
|
+
introDuration?: number
|
|
66
|
+
}) {
|
|
67
|
+
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
|
68
|
+
const [active, setActive] = useState(false)
|
|
69
|
+
const [lightboxStart, setLightboxStart] =
|
|
70
|
+
useState<LifelineLightboxStart | null>(null)
|
|
71
|
+
const cardRef = useRef<HTMLDivElement>(null)
|
|
72
|
+
const drag = useRef({
|
|
73
|
+
startX: 0,
|
|
74
|
+
startY: 0,
|
|
75
|
+
baseX: 0,
|
|
76
|
+
baseY: 0,
|
|
77
|
+
moved: false,
|
|
78
|
+
slop: CLICK_SLOP,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
82
|
+
// The desktop track scrubs on drag — a card drag must not reach it.
|
|
83
|
+
event.stopPropagation()
|
|
84
|
+
event.preventDefault()
|
|
85
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
86
|
+
drag.current = {
|
|
87
|
+
startX: event.clientX,
|
|
88
|
+
startY: event.clientY,
|
|
89
|
+
baseX: offset.x,
|
|
90
|
+
baseY: offset.y,
|
|
91
|
+
moved: false,
|
|
92
|
+
slop: event.pointerType === "touch" ? TOUCH_CLICK_SLOP : CLICK_SLOP,
|
|
93
|
+
}
|
|
94
|
+
setActive(true)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
98
|
+
if (!active) return
|
|
99
|
+
const dx = event.clientX - drag.current.startX
|
|
100
|
+
const dy = event.clientY - drag.current.startY
|
|
101
|
+
if (Math.hypot(dx, dy) > drag.current.slop) drag.current.moved = true
|
|
102
|
+
setOffset({ x: drag.current.baseX + dx, y: drag.current.baseY + dy })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The card's real geometry: bounding-box center (rotation preserves
|
|
106
|
+
// it) plus untransformed layout size — never the rotated hull, which
|
|
107
|
+
// is what made the lightbox clone jump on open.
|
|
108
|
+
const measureCard = (): LifelineLightboxStart | null => {
|
|
109
|
+
const el = cardRef.current
|
|
110
|
+
if (!el) return null
|
|
111
|
+
const rect = el.getBoundingClientRect()
|
|
112
|
+
// Recover the rendered scale (hover grows the card 3%) from the
|
|
113
|
+
// rotated hull: for tilt θ, hullWidth = (w·cosθ + h·sinθ)·scale.
|
|
114
|
+
const w0 = el.offsetWidth
|
|
115
|
+
const h0 = el.offsetHeight
|
|
116
|
+
const rad = Math.abs((rotate * Math.PI) / 180)
|
|
117
|
+
const hull = w0 * Math.cos(rad) + h0 * Math.sin(rad)
|
|
118
|
+
const scale = hull > 0 ? rect.width / hull : 1
|
|
119
|
+
return {
|
|
120
|
+
cx: rect.left + rect.width / 2,
|
|
121
|
+
cy: rect.top + rect.height / 2,
|
|
122
|
+
w: w0 * scale,
|
|
123
|
+
h: h0 * scale,
|
|
124
|
+
mediaTime: el.querySelector("video")?.currentTime,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
|
129
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
130
|
+
setActive(false)
|
|
131
|
+
// A press that never travelled is a click — expand to the lightbox.
|
|
132
|
+
if (!drag.current.moved && !lightboxStart) {
|
|
133
|
+
setLightboxStart(measureCard())
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The browser claiming the gesture (a vertical pan-y scroll on
|
|
138
|
+
// touch) is not a click — reset without opening.
|
|
139
|
+
const onPointerCancel = (event: PointerEvent<HTMLDivElement>) => {
|
|
140
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
141
|
+
setActive(false)
|
|
142
|
+
setOffset({ x: drag.current.baseX, y: drag.current.baseY })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<>
|
|
147
|
+
<div
|
|
148
|
+
ref={cardRef}
|
|
149
|
+
data-lifeline-interactive=""
|
|
150
|
+
className={cn(
|
|
151
|
+
// pan-y keeps page scrolling alive on touch: a vertical swipe
|
|
152
|
+
// starting on a card scrolls the timeline (the browser claims
|
|
153
|
+
// the gesture and fires pointercancel); horizontal drags move
|
|
154
|
+
// the card.
|
|
155
|
+
"group/photo pointer-events-auto cursor-grab touch-pan-y",
|
|
156
|
+
active ? "z-50 cursor-grabbing" : "z-20 hover:z-40",
|
|
157
|
+
lightboxStart && "invisible",
|
|
158
|
+
className,
|
|
159
|
+
)}
|
|
160
|
+
style={
|
|
161
|
+
{
|
|
162
|
+
...style,
|
|
163
|
+
width,
|
|
164
|
+
transform: `translate(${offset.x}px, ${offset.y}px) rotate(${rotate}deg)`,
|
|
165
|
+
} as CSSProperties
|
|
166
|
+
}
|
|
167
|
+
onPointerDown={onPointerDown}
|
|
168
|
+
onPointerMove={onPointerMove}
|
|
169
|
+
onPointerUp={onPointerUp}
|
|
170
|
+
onPointerCancel={onPointerCancel}
|
|
171
|
+
>
|
|
172
|
+
<div
|
|
173
|
+
className={cn(
|
|
174
|
+
"overflow-hidden rounded-xl shadow-xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out",
|
|
175
|
+
animateIntro && "lifeline-marker-intro",
|
|
176
|
+
active
|
|
177
|
+
? "scale-[1.05] shadow-2xl"
|
|
178
|
+
: "group-hover/photo:scale-[1.03] group-hover/photo:shadow-2xl",
|
|
179
|
+
)}
|
|
180
|
+
style={
|
|
181
|
+
animateIntro
|
|
182
|
+
? ({
|
|
183
|
+
animationDelay: `${introDelay}ms`,
|
|
184
|
+
"--lifeline-marker-fade-ms": `${introDuration}ms`,
|
|
185
|
+
} as CSSProperties)
|
|
186
|
+
: undefined
|
|
187
|
+
}
|
|
188
|
+
>
|
|
189
|
+
<LifelineEventMedia
|
|
190
|
+
media={photo}
|
|
191
|
+
className="pointer-events-none block w-full"
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
{lightboxStart && (
|
|
196
|
+
<LifelineLightbox
|
|
197
|
+
photo={photo}
|
|
198
|
+
rotate={rotate}
|
|
199
|
+
start={lightboxStart}
|
|
200
|
+
getHome={measureCard}
|
|
201
|
+
onClosed={() => setLightboxStart(null)}
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
</>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function FloatingCard({
|
|
209
|
+
photo,
|
|
210
|
+
stackIndex,
|
|
211
|
+
stackCount,
|
|
212
|
+
x,
|
|
213
|
+
defaultY,
|
|
214
|
+
width,
|
|
215
|
+
animateIntro,
|
|
216
|
+
introDelay,
|
|
217
|
+
introDuration,
|
|
218
|
+
}: {
|
|
219
|
+
photo: LifelinePhoto
|
|
220
|
+
stackIndex: number
|
|
221
|
+
stackCount: number
|
|
222
|
+
/** Resolved left position within the track. */
|
|
223
|
+
x: number
|
|
224
|
+
/** Cascade position when the photo has no explicit y. */
|
|
225
|
+
defaultY: number
|
|
226
|
+
width: number
|
|
227
|
+
animateIntro: boolean
|
|
228
|
+
introDelay: number
|
|
229
|
+
introDuration: number
|
|
230
|
+
}) {
|
|
231
|
+
const y = photo.y ?? defaultY
|
|
232
|
+
// Rolled once per mount: solo cards flip a coin for direction;
|
|
233
|
+
// neighbors in a stack lean away from each other so the pile reads
|
|
234
|
+
// as scattered.
|
|
235
|
+
const [mountTilt] = useState(() => {
|
|
236
|
+
const unit = seededUnit(`${photo.src}:${stackIndex}`)
|
|
237
|
+
const sign =
|
|
238
|
+
stackCount > 1
|
|
239
|
+
? stackIndex % 2 === 0
|
|
240
|
+
? -1
|
|
241
|
+
: 1
|
|
242
|
+
: unit > 0.5
|
|
243
|
+
? 1
|
|
244
|
+
: -1
|
|
245
|
+
return sign * randomTilt(`${photo.src}:${stackIndex}:tilt`)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<LifelinePhotoCard
|
|
250
|
+
photo={photo}
|
|
251
|
+
rotate={photo.rotate ?? mountTilt}
|
|
252
|
+
width={width}
|
|
253
|
+
className="absolute"
|
|
254
|
+
style={{ left: x, top: `calc(var(--lifeline-rail) + ${y}px)` }}
|
|
255
|
+
animateIntro={animateIntro}
|
|
256
|
+
introDelay={introDelay}
|
|
257
|
+
introDuration={introDuration}
|
|
258
|
+
/>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Always-visible media scattered over the timeline — anchored to their
|
|
264
|
+
* marker's slot, tilted and overlapping like photos in a notebook.
|
|
265
|
+
* Rendered inside the transformed track, so they ride the scroll;
|
|
266
|
+
* dragging repositions a card for the session.
|
|
267
|
+
*/
|
|
268
|
+
export function LifelineFloatingPhotos({
|
|
269
|
+
markers,
|
|
270
|
+
offsets,
|
|
271
|
+
widths,
|
|
272
|
+
animateIntro = false,
|
|
273
|
+
getIntroDelay,
|
|
274
|
+
getIntroDuration,
|
|
275
|
+
}: {
|
|
276
|
+
markers: LifelineMarker[]
|
|
277
|
+
offsets: number[]
|
|
278
|
+
widths: number[]
|
|
279
|
+
animateIntro?: boolean
|
|
280
|
+
getIntroDelay?: (markerIndex: number) => number
|
|
281
|
+
getIntroDuration?: (markerIndex: number) => number
|
|
282
|
+
}) {
|
|
283
|
+
const trackEnd =
|
|
284
|
+
offsets.length > 0
|
|
285
|
+
? offsets[offsets.length - 1] + widths[widths.length - 1]
|
|
286
|
+
: 0
|
|
287
|
+
|
|
288
|
+
// Intro sync: a card fades in when the rail tip reaches it, i.e. on
|
|
289
|
+
// the schedule of the marker whose slot its center sits over — which
|
|
290
|
+
// is usually days past its anchor.
|
|
291
|
+
const markerIndexAt = (x: number) => {
|
|
292
|
+
for (let index = offsets.length - 1; index >= 0; index--) {
|
|
293
|
+
if (offsets[index] <= x) return index
|
|
294
|
+
}
|
|
295
|
+
return 0
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
|
300
|
+
{markers.map((marker, index) => {
|
|
301
|
+
if (!marker.photos?.length) return null
|
|
302
|
+
|
|
303
|
+
// The card's free run: after this day's own text column, up to
|
|
304
|
+
// the start of the next day that has text of its own.
|
|
305
|
+
const zoneStart =
|
|
306
|
+
offsets[index] + (marker.events.length > 0 ? TEXT_ZONE : 0)
|
|
307
|
+
const nextTextIndex = markers.findIndex(
|
|
308
|
+
(candidate, candidateIndex) =>
|
|
309
|
+
candidateIndex > index && candidate.events.length > 0,
|
|
310
|
+
)
|
|
311
|
+
const zoneEnd =
|
|
312
|
+
nextTextIndex === -1 ? trackEnd : offsets[nextTextIndex]
|
|
313
|
+
|
|
314
|
+
// Stacks center as a group so a lone card sits in the middle
|
|
315
|
+
// of the gap and a pile spreads evenly around it. Each card
|
|
316
|
+
// starts STACK_OVERLAP of the way along the one beneath it.
|
|
317
|
+
const steps: number[] = []
|
|
318
|
+
let fan = 0
|
|
319
|
+
for (const stacked of marker.photos) {
|
|
320
|
+
steps.push(fan)
|
|
321
|
+
fan += (stacked.width ?? CARD_WIDTH) * STACK_OVERLAP
|
|
322
|
+
}
|
|
323
|
+
const photoCount = marker.photos.length
|
|
324
|
+
const lastPhoto = marker.photos[photoCount - 1]
|
|
325
|
+
const groupWidth =
|
|
326
|
+
steps[steps.length - 1] + (lastPhoto.width ?? CARD_WIDTH)
|
|
327
|
+
|
|
328
|
+
return marker.photos.map((photo, photoIndex) => {
|
|
329
|
+
const width = photo.width ?? CARD_WIDTH
|
|
330
|
+
// Default home: centered in the text-free run between this
|
|
331
|
+
// day's events and the next day that has text — comfortably
|
|
332
|
+
// away from both columns.
|
|
333
|
+
const x =
|
|
334
|
+
photo.x !== undefined
|
|
335
|
+
? offsets[index] + photo.x * widths[index]
|
|
336
|
+
: Math.max(
|
|
337
|
+
zoneStart,
|
|
338
|
+
zoneStart +
|
|
339
|
+
(zoneEnd - zoneStart - groupWidth) / 2 +
|
|
340
|
+
steps[photoIndex],
|
|
341
|
+
)
|
|
342
|
+
const introIndex = markerIndexAt(x + width / 2)
|
|
343
|
+
// The last card of a stack sits level with the event text;
|
|
344
|
+
// earlier cards hang progressively lower — the diagonal.
|
|
345
|
+
const defaultY =
|
|
346
|
+
EVENT_TOP + (photoCount - 1 - photoIndex) * CASCADE_Y
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<FloatingCard
|
|
350
|
+
key={`${marker.id}-${photoIndex}`}
|
|
351
|
+
photo={photo}
|
|
352
|
+
stackIndex={photoIndex}
|
|
353
|
+
stackCount={photoCount}
|
|
354
|
+
x={x}
|
|
355
|
+
defaultY={defaultY}
|
|
356
|
+
width={width}
|
|
357
|
+
animateIntro={animateIntro}
|
|
358
|
+
introDelay={getIntroDelay?.(introIndex) ?? 0}
|
|
359
|
+
introDuration={getIntroDuration?.(introIndex) ?? 420}
|
|
360
|
+
/>
|
|
361
|
+
)
|
|
362
|
+
})
|
|
363
|
+
})}
|
|
364
|
+
</div>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { cn } from "../lib/cn"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The page framing the Lifeline expects.
|
|
6
|
+
*
|
|
7
|
+
* The rail is not sized by CSS — on desktop it measures where to start
|
|
8
|
+
* and end from the nav: `data-site-nav-logo` gives it the start, and the
|
|
9
|
+
* right edge of `data-site-nav-inner` gives it the end. That is what
|
|
10
|
+
* keeps the timeline inset from the viewport and aligned with the rest
|
|
11
|
+
* of the page, and it's the span the intro animation draws across.
|
|
12
|
+
*
|
|
13
|
+
* Change `containerClassName` on both the nav and the footer together
|
|
14
|
+
* and the rail follows. Drop the nav entirely and the rail falls back
|
|
15
|
+
* to filling its own container, edge to edge.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CONTAINER = "mx-auto flex w-full max-w-5xl items-center px-6"
|
|
19
|
+
|
|
20
|
+
export function LifelineShell({
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
}: {
|
|
24
|
+
children: ReactNode
|
|
25
|
+
className?: string
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={cn(
|
|
30
|
+
"flex h-dvh flex-col overflow-hidden bg-background-primary text-fg-primary antialiased transition-colors duration-300",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function LifelineNav({
|
|
40
|
+
logo,
|
|
41
|
+
logoHref = "/",
|
|
42
|
+
logoLabel = "Home",
|
|
43
|
+
children,
|
|
44
|
+
className,
|
|
45
|
+
containerClassName,
|
|
46
|
+
}: {
|
|
47
|
+
/** Rendered inside the marked anchor — the rail starts at its left edge. */
|
|
48
|
+
logo: ReactNode
|
|
49
|
+
logoHref?: string
|
|
50
|
+
/** Accessible name for the logo link. */
|
|
51
|
+
logoLabel?: string
|
|
52
|
+
/** Anything on the right: links, a theme switcher. */
|
|
53
|
+
children?: ReactNode
|
|
54
|
+
className?: string
|
|
55
|
+
containerClassName?: string
|
|
56
|
+
}) {
|
|
57
|
+
return (
|
|
58
|
+
<nav
|
|
59
|
+
className={cn(
|
|
60
|
+
"fixed inset-x-0 top-0 z-50 border-b border-border-primary bg-background-primary/80 backdrop-blur-xl transition-colors duration-300",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<div
|
|
65
|
+
data-site-nav-inner
|
|
66
|
+
className={cn(CONTAINER, "h-16 justify-between", containerClassName)}
|
|
67
|
+
>
|
|
68
|
+
<a
|
|
69
|
+
href={logoHref}
|
|
70
|
+
data-site-nav-logo
|
|
71
|
+
aria-label={logoLabel}
|
|
72
|
+
className="cursor-pointer text-fg-primary transition-[color,opacity] duration-300 hover:opacity-70"
|
|
73
|
+
>
|
|
74
|
+
{logo}
|
|
75
|
+
</a>
|
|
76
|
+
|
|
77
|
+
{children ? (
|
|
78
|
+
<div className="flex items-center gap-8">{children}</div>
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
</nav>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The stage. `pt-16` clears the fixed nav; `md:overflow-hidden` hands
|
|
87
|
+
* scrolling to the horizontal scrub above the mobile breakpoint.
|
|
88
|
+
*/
|
|
89
|
+
export function LifelineStage({
|
|
90
|
+
children,
|
|
91
|
+
className,
|
|
92
|
+
}: {
|
|
93
|
+
children: ReactNode
|
|
94
|
+
className?: string
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<main
|
|
98
|
+
className={cn(
|
|
99
|
+
"flex-1 min-h-0 overflow-y-auto pt-16 md:overflow-hidden",
|
|
100
|
+
className,
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</main>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function LifelineFooter({
|
|
109
|
+
children,
|
|
110
|
+
className,
|
|
111
|
+
containerClassName,
|
|
112
|
+
}: {
|
|
113
|
+
children?: ReactNode
|
|
114
|
+
className?: string
|
|
115
|
+
containerClassName?: string
|
|
116
|
+
}) {
|
|
117
|
+
return (
|
|
118
|
+
<footer
|
|
119
|
+
className={cn(
|
|
120
|
+
"shrink-0 border-t border-border-primary bg-background-primary/95 backdrop-blur-sm transition-colors duration-300",
|
|
121
|
+
className,
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
<div
|
|
125
|
+
className={cn(
|
|
126
|
+
CONTAINER,
|
|
127
|
+
"h-16 justify-between gap-6",
|
|
128
|
+
containerClassName,
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{children}
|
|
132
|
+
</div>
|
|
133
|
+
</footer>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { LifelineMarker } from "./types"
|
|
2
|
+
|
|
3
|
+
/** Stable 0..1 noise so photo tilts match across SSR and the client. */
|
|
4
|
+
export function seededUnit(seed: string) {
|
|
5
|
+
let hash = 2166136261
|
|
6
|
+
for (let index = 0; index < seed.length; index++) {
|
|
7
|
+
hash ^= seed.charCodeAt(index)
|
|
8
|
+
hash = Math.imul(hash, 16777619)
|
|
9
|
+
}
|
|
10
|
+
return (hash >>> 0) / 4294967296
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function clamp(value: number, min: number, max: number) {
|
|
14
|
+
return Math.min(max, Math.max(min, value))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A composited layer resting on a fractional offset resamples its whole
|
|
19
|
+
* subtree — text goes soft. Snapping to the device pixel grid (not whole
|
|
20
|
+
* CSS pixels) keeps half-pixel steps on retina, so motion stays smooth.
|
|
21
|
+
*/
|
|
22
|
+
export function snapToDevicePixel(value: number) {
|
|
23
|
+
const dpr = window.devicePixelRatio || 1
|
|
24
|
+
return Math.round(value * dpr) / dpr
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hasMarkerContent(marker: LifelineMarker) {
|
|
28
|
+
return (
|
|
29
|
+
marker.events.length > 0 ||
|
|
30
|
+
(marker.companies?.length ?? 0) > 0 ||
|
|
31
|
+
(marker.mentors?.length ?? 0) > 0 ||
|
|
32
|
+
(marker.met?.length ?? 0) > 0
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hasMarkerPeople(marker: LifelineMarker) {
|
|
37
|
+
return (marker.mentors?.length ?? 0) > 0 || (marker.met?.length ?? 0) > 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getMarkerHeight(marker: LifelineMarker, nextYear?: number) {
|
|
41
|
+
const hasContent = hasMarkerContent(marker)
|
|
42
|
+
const hasPeople = hasMarkerPeople(marker)
|
|
43
|
+
|
|
44
|
+
if (!hasContent) return 48
|
|
45
|
+
|
|
46
|
+
const peopleOnly =
|
|
47
|
+
hasPeople &&
|
|
48
|
+
marker.events.length === 0 &&
|
|
49
|
+
(marker.companies?.length ?? 0) === 0
|
|
50
|
+
|
|
51
|
+
let height = 96
|
|
52
|
+
|
|
53
|
+
if (marker.companies?.length) height += 28
|
|
54
|
+
height += marker.events.length * 44
|
|
55
|
+
|
|
56
|
+
if (peopleOnly) height += 88
|
|
57
|
+
else if (hasPeople) height += 108
|
|
58
|
+
|
|
59
|
+
if (!nextYear) return Math.min(520, Math.max(peopleOnly ? 148 : 188, height))
|
|
60
|
+
|
|
61
|
+
const gap = Math.max(1, nextYear - marker.year)
|
|
62
|
+
height += Math.min(32, gap * 3)
|
|
63
|
+
|
|
64
|
+
return Math.min(520, Math.max(peopleOnly ? 148 : 188, height))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getMarkerWidth(marker: LifelineMarker, nextYear?: number) {
|
|
68
|
+
const hasContent = hasMarkerContent(marker)
|
|
69
|
+
const hasPeople = hasMarkerPeople(marker)
|
|
70
|
+
|
|
71
|
+
if (!nextYear) return hasContent ? 360 : 80
|
|
72
|
+
if (!hasContent) return 80
|
|
73
|
+
|
|
74
|
+
const peopleOnly =
|
|
75
|
+
hasPeople &&
|
|
76
|
+
marker.events.length === 0 &&
|
|
77
|
+
(marker.companies?.length ?? 0) === 0
|
|
78
|
+
|
|
79
|
+
if (peopleOnly) return 220
|
|
80
|
+
|
|
81
|
+
const gap = Math.max(1, nextYear - marker.year)
|
|
82
|
+
return Math.min(420, Math.max(290, gap * 36))
|
|
83
|
+
}
|