@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,302 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react"
12
+ import type { LifelineEventEffect } from "./types"
13
+
14
+ /** Tweak these */
15
+ const DURATION_S = 7.5
16
+ const MAX_DPR = 1.5
17
+ /** Wait for the theme cross-fade before the first burst. */
18
+ const NIGHTFALL_MS = 400
19
+
20
+ type Palette = [number[], number[], number[]]
21
+
22
+ const PALETTES: Record<LifelineEventEffect, Palette> = {
23
+ // Old Glory red / white / blue
24
+ fireworks: [
25
+ [0.9, 0.15, 0.25],
26
+ [1.0, 1.0, 1.0],
27
+ [0.25, 0.45, 0.95],
28
+ ],
29
+ // celeste / white / pale celeste
30
+ "fireworks-argentina": [
31
+ [0.45, 0.75, 0.98],
32
+ [1.0, 1.0, 1.0],
33
+ [0.7, 0.87, 1.0],
34
+ ],
35
+ }
36
+
37
+ const VERTEX_SHADER = `
38
+ attribute vec2 a_pos;
39
+ void main() {
40
+ gl_Position = vec4(a_pos, 0.0, 1.0);
41
+ }
42
+ `
43
+
44
+ /**
45
+ * Additive point-glow fireworks in Old Glory red, white, and blue,
46
+ * composited over a night-sky scrim via premultiplied canvas alpha.
47
+ */
48
+ const FRAGMENT_SHADER = `
49
+ precision highp float;
50
+
51
+ uniform vec2 u_res;
52
+ uniform float u_time;
53
+ uniform float u_dur;
54
+ uniform vec3 u_c0;
55
+ uniform vec3 u_c1;
56
+ uniform vec3 u_c2;
57
+
58
+ #define TAU 6.28318530718
59
+ #define N_FIREWORKS 10
60
+ #define N_PARTICLES 42
61
+
62
+ float hash(float n) {
63
+ return fract(sin(n) * 43758.5453123);
64
+ }
65
+
66
+ vec3 palette(float m) {
67
+ if (m < 0.5) return u_c0;
68
+ if (m < 1.5) return u_c1;
69
+ return u_c2;
70
+ }
71
+
72
+ void main() {
73
+ vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;
74
+ float t = u_time;
75
+ float env = smoothstep(0.0, 0.5, t) * (1.0 - smoothstep(u_dur - 1.0, u_dur, t));
76
+
77
+ vec3 col = vec3(0.0);
78
+
79
+ for (int i = 0; i < N_FIREWORKS; i++) {
80
+ float fi = float(i);
81
+ float t0 = 0.35 + fi * (u_dur - 2.8) / float(N_FIREWORKS) + hash(fi * 7.31) * 0.3;
82
+ // No flow control in the loop — some WebGL1 driver translations
83
+ // mishandle continue, so inactive bursts multiply to zero instead.
84
+ float active = step(t0, t) * step(t, t0 + 1.8);
85
+ float lt = clamp((t - t0) / 1.8, 0.0, 1.0);
86
+
87
+ vec2 center = vec2((hash(fi * 3.7) - 0.5) * 1.6, -0.05 + hash(fi * 9.1) * 0.5);
88
+ vec3 base = palette(mod(fi, 3.0)); // strict red / white / blue rotation
89
+ // Ramp in fast so overlapping particles at ignition don't stack
90
+ // into a blown-out ball, then decay.
91
+ float fade = exp(-lt * 4.0) * min(1.0, lt * 6.0);
92
+
93
+ for (int j = 0; j < N_PARTICLES; j++) {
94
+ float fj = float(j);
95
+ float angle = (fj / float(N_PARTICLES)) * TAU + hash(fi * 100.0 + fj) * 0.15;
96
+ float speed = 0.16 + 0.22 * hash(fj * 7.77 + fi * 31.3);
97
+
98
+ vec2 p = center + vec2(cos(angle), sin(angle)) * speed * sqrt(lt);
99
+ p.y -= 0.09 * lt * lt; // gravity
100
+
101
+ float d = max(length(uv - p), 0.004);
102
+ float sparkle = 0.7 + 0.3 * sin(30.0 * lt + fj * 1.7);
103
+ col += base * active * fade * sparkle * 0.0006 / (d * d);
104
+ }
105
+ }
106
+
107
+ col = clamp(col * env, 0.0, 1.0);
108
+ float spark = max(col.r, max(col.g, col.b));
109
+ float scrim = 0.45 * env;
110
+ gl_FragColor = vec4(col, max(spark, scrim));
111
+ }
112
+ `
113
+
114
+ interface LifelineFireworksApi {
115
+ launch: (effect: LifelineEventEffect) => void
116
+ }
117
+
118
+ const LifelineFireworksContext = createContext<LifelineFireworksApi | null>(
119
+ null,
120
+ )
121
+
122
+ export function useLifelineFireworks() {
123
+ return useContext(LifelineFireworksContext)
124
+ }
125
+
126
+ function FireworksCanvas({
127
+ palette,
128
+ onDone,
129
+ }: {
130
+ palette: Palette
131
+ onDone: () => void
132
+ }) {
133
+ const paletteRef = useRef(palette)
134
+ paletteRef.current = palette
135
+ const canvasRef = useRef<HTMLCanvasElement>(null)
136
+ const onDoneRef = useRef(onDone)
137
+ onDoneRef.current = onDone
138
+
139
+ useEffect(() => {
140
+ const canvas = canvasRef.current
141
+ if (!canvas) return
142
+
143
+ const gl = canvas.getContext("webgl", {
144
+ alpha: true,
145
+ premultipliedAlpha: true,
146
+ antialias: false,
147
+ })
148
+ if (!gl) {
149
+ onDoneRef.current()
150
+ return
151
+ }
152
+
153
+ const compile = (type: number, source: string) => {
154
+ const shader = gl.createShader(type)!
155
+ gl.shaderSource(shader, source)
156
+ gl.compileShader(shader)
157
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
158
+ console.warn("fireworks shader:", gl.getShaderInfoLog(shader))
159
+ }
160
+ return shader
161
+ }
162
+
163
+ const program = gl.createProgram()!
164
+ gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER))
165
+ gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER))
166
+ gl.linkProgram(program)
167
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
168
+ console.warn("fireworks link:", gl.getProgramInfoLog(program))
169
+ onDoneRef.current()
170
+ return
171
+ }
172
+ gl.useProgram(program)
173
+
174
+ const buffer = gl.createBuffer()
175
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
176
+ gl.bufferData(
177
+ gl.ARRAY_BUFFER,
178
+ new Float32Array([-1, -1, 3, -1, -1, 3]),
179
+ gl.STATIC_DRAW,
180
+ )
181
+ const aPos = gl.getAttribLocation(program, "a_pos")
182
+ gl.enableVertexAttribArray(aPos)
183
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
184
+
185
+ const uRes = gl.getUniformLocation(program, "u_res")
186
+ const uTime = gl.getUniformLocation(program, "u_time")
187
+ const uDur = gl.getUniformLocation(program, "u_dur")
188
+
189
+ const [c0, c1, c2] = paletteRef.current
190
+ gl.uniform3f(gl.getUniformLocation(program, "u_c0"), c0[0], c0[1], c0[2])
191
+ gl.uniform3f(gl.getUniformLocation(program, "u_c1"), c1[0], c1[1], c1[2])
192
+ gl.uniform3f(gl.getUniformLocation(program, "u_c2"), c2[0], c2[1], c2[2])
193
+
194
+ const resize = () => {
195
+ const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
196
+ canvas.width = Math.round(window.innerWidth * dpr)
197
+ canvas.height = Math.round(window.innerHeight * dpr)
198
+ gl.viewport(0, 0, canvas.width, canvas.height)
199
+ }
200
+ resize()
201
+ window.addEventListener("resize", resize)
202
+
203
+ let frame = 0
204
+ const start = performance.now()
205
+
206
+ const step = (now: number) => {
207
+ const t = (now - start) / 1000
208
+ if (t >= DURATION_S) {
209
+ onDoneRef.current()
210
+ return
211
+ }
212
+
213
+ gl.uniform2f(uRes, canvas.width, canvas.height)
214
+ gl.uniform1f(uTime, t)
215
+ gl.uniform1f(uDur, DURATION_S)
216
+ gl.clearColor(0, 0, 0, 0)
217
+ gl.clear(gl.COLOR_BUFFER_BIT)
218
+ gl.drawArrays(gl.TRIANGLES, 0, 3)
219
+
220
+ frame = requestAnimationFrame(step)
221
+ }
222
+ frame = requestAnimationFrame(step)
223
+
224
+ return () => {
225
+ cancelAnimationFrame(frame)
226
+ window.removeEventListener("resize", resize)
227
+ // No manual loseContext: StrictMode re-runs this effect on the
228
+ // same canvas, and a lost context can never compile again.
229
+ }
230
+ }, [])
231
+
232
+ return (
233
+ <canvas
234
+ ref={canvasRef}
235
+ aria-hidden="true"
236
+ className="pointer-events-none fixed inset-0 z-[70] h-full w-full"
237
+ />
238
+ )
239
+ }
240
+
241
+ function isDarkDocument() {
242
+ return document.documentElement.classList.contains("dark")
243
+ }
244
+
245
+ function setDocumentDark(dark: boolean) {
246
+ document.documentElement.classList.toggle("dark", dark)
247
+ }
248
+
249
+ export function LifelineFireworksProvider({
250
+ children,
251
+ }: {
252
+ children: ReactNode
253
+ }) {
254
+ const [playing, setPlaying] = useState(false)
255
+ const [effect, setEffect] = useState<LifelineEventEffect>("fireworks")
256
+ const restoreThemeRef = useRef<"light" | null>(null)
257
+ const nightfallRef = useRef(0)
258
+ const playingRef = useRef(false)
259
+ playingRef.current = playing
260
+
261
+ useEffect(() => {
262
+ return () => window.clearTimeout(nightfallRef.current)
263
+ }, [])
264
+
265
+ const launch = useCallback((nextEffect: LifelineEventEffect) => {
266
+ if (playingRef.current) return
267
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
268
+
269
+ setEffect(nextEffect)
270
+
271
+ // Fireworks belong in the dark: switch a light page to dark for
272
+ // the show, and restore afterwards.
273
+ if (!isDarkDocument()) {
274
+ restoreThemeRef.current = "light"
275
+ setDocumentDark(true)
276
+ window.clearTimeout(nightfallRef.current)
277
+ nightfallRef.current = window.setTimeout(
278
+ () => setPlaying(true),
279
+ NIGHTFALL_MS,
280
+ )
281
+ return
282
+ }
283
+
284
+ restoreThemeRef.current = null
285
+ setPlaying(true)
286
+ }, [])
287
+
288
+ const done = useCallback(() => {
289
+ setPlaying(false)
290
+ if (restoreThemeRef.current) {
291
+ setDocumentDark(false)
292
+ restoreThemeRef.current = null
293
+ }
294
+ }, [])
295
+
296
+ return (
297
+ <LifelineFireworksContext.Provider value={{ launch }}>
298
+ {children}
299
+ {playing && <FireworksCanvas palette={PALETTES[effect]} onDone={done} />}
300
+ </LifelineFireworksContext.Provider>
301
+ )
302
+ }
@@ -0,0 +1,269 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ type ReactNode,
11
+ } from "react"
12
+ import { clamp, snapToDevicePixel } from "./lifeline-utils"
13
+ import type { LifelineEventImage } from "./types"
14
+
15
+ /** Tweak these */
16
+ const FOLLOW_EASE = 0.16
17
+ const TILT_FACTOR = 0.14
18
+ const TILT_MAX_DEG = 7
19
+ const TILT_EASE = 0.1
20
+ const CURSOR_OFFSET_X = 24
21
+ const CURSOR_OFFSET_Y = 96
22
+
23
+ interface LifelineHoverImageApi {
24
+ show: (image: LifelineEventImage) => void
25
+ hide: () => void
26
+ }
27
+
28
+ interface LifelineHoverImageProviderProps {
29
+ children: ReactNode
30
+ /** Warmed into the browser cache during idle so swaps are instant. */
31
+ preload?: LifelineEventImage[]
32
+ }
33
+
34
+ const LifelineHoverImageContext = createContext<LifelineHoverImageApi | null>(
35
+ null,
36
+ )
37
+
38
+ export function useLifelineHoverImage() {
39
+ return useContext(LifelineHoverImageContext)
40
+ }
41
+
42
+ /**
43
+ * A cursor-following image reveal. The floating element lives at the
44
+ * provider level — it must stay outside the transformed track, since
45
+ * position: fixed resolves against the nearest transformed ancestor.
46
+ */
47
+ export function LifelineHoverImageProvider({
48
+ children,
49
+ preload,
50
+ }: LifelineHoverImageProviderProps) {
51
+ const containerRef = useRef<HTMLDivElement>(null)
52
+ const imageRef = useRef<HTMLImageElement>(null)
53
+ const videoRef = useRef<HTMLVideoElement>(null)
54
+ const state = useRef({
55
+ x: 0,
56
+ y: 0,
57
+ targetX: 0,
58
+ targetY: 0,
59
+ tilt: 0,
60
+ visible: false,
61
+ hoverCapable: false,
62
+ frame: 0,
63
+ })
64
+
65
+ useEffect(() => {
66
+ state.current.hoverCapable = window.matchMedia(
67
+ "(hover: hover) and (pointer: fine)",
68
+ ).matches
69
+
70
+ const onMouseMove = (event: MouseEvent) => {
71
+ state.current.targetX = event.clientX
72
+ state.current.targetY = event.clientY
73
+ }
74
+
75
+ window.addEventListener("mousemove", onMouseMove, { passive: true })
76
+ return () => {
77
+ window.removeEventListener("mousemove", onMouseMove)
78
+ cancelAnimationFrame(state.current.frame)
79
+ }
80
+ }, [])
81
+
82
+ useEffect(() => {
83
+ if (!preload?.length || !state.current.hoverCapable) return
84
+
85
+ const warm = () => {
86
+ preload.forEach(({ src }) => {
87
+ const image = new window.Image()
88
+ image.src = src
89
+ })
90
+ }
91
+
92
+ if (typeof window.requestIdleCallback === "function") {
93
+ const handle = window.requestIdleCallback(warm)
94
+ return () => window.cancelIdleCallback(handle)
95
+ }
96
+
97
+ const timeout = window.setTimeout(warm, 2000)
98
+ return () => window.clearTimeout(timeout)
99
+ }, [preload])
100
+
101
+ const step = useCallback(() => {
102
+ const s = state.current
103
+ const container = containerRef.current
104
+ if (!container) return
105
+
106
+ const dx = s.targetX - s.x
107
+ s.x += dx * FOLLOW_EASE
108
+ s.y += (s.targetY - s.y) * FOLLOW_EASE
109
+
110
+ const targetTilt = clamp(dx * TILT_FACTOR, -TILT_MAX_DEG, TILT_MAX_DEG)
111
+ s.tilt += (targetTilt - s.tilt) * TILT_EASE
112
+
113
+ // The ease is asymptotic — it never actually arrives, so the card
114
+ // rests on a fractional offset with a residual tilt and the browser
115
+ // resamples it soft. Land it: snap sub-threshold deltas to done.
116
+ if (
117
+ Math.abs(s.targetX - s.x) < 0.1 &&
118
+ Math.abs(s.targetY - s.y) < 0.1 &&
119
+ Math.abs(s.tilt) < 0.05
120
+ ) {
121
+ s.x = s.targetX
122
+ s.y = s.targetY
123
+ s.tilt = 0
124
+ }
125
+
126
+ const rotate = s.tilt === 0 ? "" : ` rotate(${s.tilt}deg)`
127
+ container.style.transform = `translate3d(${snapToDevicePixel(
128
+ s.x + CURSOR_OFFSET_X,
129
+ )}px, ${snapToDevicePixel(s.y - CURSOR_OFFSET_Y)}px, 0)${rotate}`
130
+
131
+ if (s.visible) {
132
+ s.frame = requestAnimationFrame(step)
133
+ }
134
+ }, [])
135
+
136
+ const api = useMemo<LifelineHoverImageApi>(
137
+ () => ({
138
+ show(image) {
139
+ const s = state.current
140
+ const container = containerRef.current
141
+ const img = imageRef.current
142
+ const video = videoRef.current
143
+ if (!s.hoverCapable || !container || !img) return
144
+
145
+ if (image.video && video) {
146
+ // Video takes over the card; the image element sits this one out.
147
+ img.style.display = "none"
148
+ video.style.display = "block"
149
+
150
+ const targetVideo = new URL(image.video, window.location.origin).href
151
+ if (video.src !== targetVideo) {
152
+ video.src = targetVideo
153
+ video.poster = image.src
154
+ }
155
+ video.play().catch(() => {
156
+ // Autoplay rejection just leaves the poster frame showing.
157
+ })
158
+
159
+ if (!s.visible) {
160
+ s.x = s.targetX
161
+ s.y = s.targetY
162
+ s.tilt = 0
163
+ }
164
+
165
+ s.visible = true
166
+ container.style.opacity = "1"
167
+ video.style.transform = "scale(1)"
168
+
169
+ cancelAnimationFrame(s.frame)
170
+ s.frame = requestAnimationFrame(step)
171
+ return
172
+ }
173
+
174
+ if (video) {
175
+ video.pause()
176
+ video.style.display = "none"
177
+ }
178
+ img.style.display = "block"
179
+
180
+ const targetSrc = new URL(image.src, window.location.origin).href
181
+
182
+ if (img.src !== targetSrc) {
183
+ // Kill the previous bitmap instantly — the browser would keep
184
+ // showing it until the new file decodes.
185
+ img.style.visibility = "hidden"
186
+ img.src = targetSrc
187
+ img.alt = image.alt
188
+
189
+ const reveal = () => {
190
+ // Only reveal if this image is still the requested one.
191
+ if (img.src === targetSrc) {
192
+ img.style.visibility = "visible"
193
+ }
194
+ }
195
+
196
+ if (img.complete) {
197
+ reveal()
198
+ } else {
199
+ img.decode().then(reveal, reveal)
200
+ }
201
+ } else {
202
+ img.style.visibility = "visible"
203
+ }
204
+
205
+ if (!s.visible) {
206
+ // Materialize at the cursor instead of flying in from the
207
+ // last resting point.
208
+ s.x = s.targetX
209
+ s.y = s.targetY
210
+ s.tilt = 0
211
+ }
212
+
213
+ s.visible = true
214
+ container.style.opacity = "1"
215
+ img.style.transform = "scale(1)"
216
+
217
+ cancelAnimationFrame(s.frame)
218
+ s.frame = requestAnimationFrame(step)
219
+ },
220
+ hide() {
221
+ const s = state.current
222
+ const container = containerRef.current
223
+ const img = imageRef.current
224
+ const video = videoRef.current
225
+ if (!container || !img) return
226
+
227
+ s.visible = false
228
+ container.style.opacity = "0"
229
+ img.style.transform = "scale(0.94)"
230
+ if (video) {
231
+ video.pause()
232
+ video.style.transform = "scale(0.94)"
233
+ }
234
+ },
235
+ }),
236
+ [step],
237
+ )
238
+
239
+ return (
240
+ <LifelineHoverImageContext.Provider value={api}>
241
+ {children}
242
+ <div
243
+ ref={containerRef}
244
+ aria-hidden="true"
245
+ className="pointer-events-none fixed left-0 top-0 z-[60] opacity-0 transition-opacity duration-200 ease-out will-change-transform"
246
+ >
247
+ {/* eslint-disable-next-line @next/next/no-img-element */}
248
+ <img
249
+ ref={imageRef}
250
+ alt=""
251
+ className="w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out"
252
+ decoding="async"
253
+ />
254
+ <video
255
+ ref={videoRef}
256
+ muted
257
+ loop
258
+ playsInline
259
+ preload="none"
260
+ // Landscape fills the same 280px card as images; portrait is
261
+ // capped by height instead, matching the floating cards'
262
+ // scale (180x320) so tall clips don't tower over the cursor.
263
+ className="max-h-[320px] w-auto max-w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out"
264
+ style={{ display: "none" }}
265
+ />
266
+ </div>
267
+ </LifelineHoverImageContext.Provider>
268
+ )
269
+ }
@@ -0,0 +1,39 @@
1
+ import type { SVGProps } from "react"
2
+
3
+ type IconProps = SVGProps<SVGSVGElement>
4
+
5
+ const iconProps = {
6
+ viewBox: "0 0 24 24",
7
+ fill: "none",
8
+ stroke: "currentColor",
9
+ strokeLinecap: "round" as const,
10
+ strokeLinejoin: "round" as const,
11
+ "aria-hidden": true as const,
12
+ }
13
+
14
+ export const LifelineFilmIcon = ({
15
+ strokeWidth = 1.75,
16
+ ...props
17
+ }: IconProps) => (
18
+ <svg {...iconProps} strokeWidth={strokeWidth} {...props}>
19
+ <rect width="18" height="18" x="3" y="3" rx="2" />
20
+ <path d="M7 3v18" />
21
+ <path d="M3 7.5h4" />
22
+ <path d="M3 12h18" />
23
+ <path d="M3 16.5h4" />
24
+ <path d="M17 3v18" />
25
+ <path d="M17 7.5h4" />
26
+ <path d="M17 16.5h4" />
27
+ </svg>
28
+ )
29
+
30
+ export const LifelineImageIcon = ({
31
+ strokeWidth = 1.75,
32
+ ...props
33
+ }: IconProps) => (
34
+ <svg {...iconProps} strokeWidth={strokeWidth} {...props}>
35
+ <rect width="18" height="18" x="3" y="3" rx="2" />
36
+ <circle cx="9" cy="9" r="2" />
37
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
38
+ </svg>
39
+ )
@@ -0,0 +1,105 @@
1
+ import { clamp } from "./lifeline-utils"
2
+
3
+ /** Tweak these */
4
+ export const LIFELINE_SLOW_YEARS = 5
5
+ export const LIFELINE_SLOW_TIME_RATIO = 0.38
6
+ export const LIFELINE_SLOW_MARKER_FADE_MS = 720
7
+ export const LIFELINE_FAST_MARKER_FADE_MS = 280
8
+ /** Set > 0 to override auto-calibrated ease power */
9
+ export const LIFELINE_EASE_POWER = 0
10
+
11
+ function smoothstep(value: number) {
12
+ const t = clamp(value, 0, 1)
13
+ return t * t * (3 - 2 * t)
14
+ }
15
+
16
+ export function getSlowTrackWidth(widths: number[]) {
17
+ return widths
18
+ .slice(0, LIFELINE_SLOW_YEARS)
19
+ .reduce((sum, width) => sum + width, 0)
20
+ }
21
+
22
+ export function getSlowTrackPortion(widths: number[]) {
23
+ const total = widths.reduce((sum, width) => sum + width, 0)
24
+ if (total <= 0) return 0
25
+ return getSlowTrackWidth(widths) / total
26
+ }
27
+
28
+ function getEasePower(widths: number[]) {
29
+ if (LIFELINE_EASE_POWER > 0) return LIFELINE_EASE_POWER
30
+
31
+ const slowPortion = getSlowTrackPortion(widths)
32
+ const softenedPivot = smoothstep(LIFELINE_SLOW_TIME_RATIO)
33
+
34
+ if (slowPortion <= 0 || softenedPivot <= 0 || softenedPivot >= 1) {
35
+ return 2.6
36
+ }
37
+
38
+ return Math.log(slowPortion) / Math.log(softenedPivot)
39
+ }
40
+
41
+ function softenedTime(elapsedMs: number, railMs: number) {
42
+ return smoothstep(elapsedMs / railMs)
43
+ }
44
+
45
+ export function trackProgressAtTime(
46
+ elapsedMs: number,
47
+ widths: number[],
48
+ railMs: number,
49
+ ) {
50
+ if (railMs <= 0) return 0
51
+
52
+ const t = softenedTime(elapsedMs, railMs)
53
+ const power = getEasePower(widths)
54
+
55
+ return clamp(Math.pow(t, power), 0, 1)
56
+ }
57
+
58
+ export function timeAtTrackProgress(
59
+ progress: number,
60
+ widths: number[],
61
+ railMs: number,
62
+ ) {
63
+ const clamped = clamp(progress, 0, 1)
64
+ if (clamped <= 0) return 0
65
+ if (clamped >= 1) return railMs
66
+
67
+ const power = getEasePower(widths)
68
+ const softened = Math.pow(clamped, 1 / power)
69
+
70
+ return invertSmoothstep(softened) * railMs
71
+ }
72
+
73
+ function invertSmoothstep(value: number) {
74
+ const target = clamp(value, 0, 1)
75
+ let lo = 0
76
+ let hi = 1
77
+
78
+ for (let i = 0; i < 20; i++) {
79
+ const mid = (lo + hi) / 2
80
+ if (smoothstep(mid) < target) lo = mid
81
+ else hi = mid
82
+ }
83
+
84
+ return (lo + hi) / 2
85
+ }
86
+
87
+ export function isSlowIntroYear(index: number) {
88
+ return index < LIFELINE_SLOW_YEARS
89
+ }
90
+
91
+ export function getTransitionMarkerFadeDuration(index: number) {
92
+ if (index < LIFELINE_SLOW_YEARS) return LIFELINE_SLOW_MARKER_FADE_MS
93
+
94
+ const rampYears = 3
95
+ const rampIndex = index - LIFELINE_SLOW_YEARS
96
+ if (rampIndex >= rampYears) return LIFELINE_FAST_MARKER_FADE_MS
97
+
98
+ const t = (rampIndex + 1) / rampYears
99
+ const blend = smoothstep(t)
100
+
101
+ return Math.round(
102
+ LIFELINE_SLOW_MARKER_FADE_MS +
103
+ (LIFELINE_FAST_MARKER_FADE_MS - LIFELINE_SLOW_MARKER_FADE_MS) * blend,
104
+ )
105
+ }