@brett_lamy/docstream 1.6.0 → 1.7.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 (43) hide show
  1. package/README.md +34 -7
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/replay/ReplayPlayer.d.ts +64 -0
  7. package/dist/replay/ReplayPlayer.d.ts.map +1 -0
  8. package/dist/replay/ReplayPlayer.js +211 -0
  9. package/dist/replay/ReplayPlayer.js.map +1 -0
  10. package/dist/replay/ReplayPreview.d.ts +24 -12
  11. package/dist/replay/ReplayPreview.d.ts.map +1 -1
  12. package/dist/replay/ReplayPreview.js +37 -32
  13. package/dist/replay/ReplayPreview.js.map +1 -1
  14. package/dist/replay/ReplayStage.d.ts +1 -1
  15. package/dist/replay/ReplayStage.js +1 -1
  16. package/dist/replay/ReplayStageImpl.js +1 -1
  17. package/dist/replay/ReplayStageImpl.js.map +1 -1
  18. package/dist/replay/demo/index.d.ts +3 -0
  19. package/dist/replay/demo/index.d.ts.map +1 -0
  20. package/dist/replay/demo/index.js +66 -0
  21. package/dist/replay/demo/index.js.map +1 -0
  22. package/dist/replay/index.d.ts +2 -2
  23. package/dist/replay/index.d.ts.map +1 -1
  24. package/dist/replay/index.js +1 -1
  25. package/dist/replay/index.js.map +1 -1
  26. package/dist/replay/types.js +3 -3
  27. package/dist/replay/types.js.map +1 -1
  28. package/dist/styles.css +565 -75
  29. package/package.json +8 -6
  30. package/src/index.ts +2 -1
  31. package/src/replay/ReplayPlayer.tsx +430 -0
  32. package/src/replay/ReplayPreview.tsx +111 -85
  33. package/src/replay/ReplayStage.tsx +1 -1
  34. package/src/replay/ReplayStageImpl.tsx +1 -1
  35. package/src/replay/demo/index.ts +67 -0
  36. package/src/replay/index.ts +6 -1
  37. package/src/replay/types.ts +3 -3
  38. package/src/styles.css +565 -75
  39. package/dist/replay/NativeReplay.d.ts +0 -9
  40. package/dist/replay/NativeReplay.d.ts.map +0 -1
  41. package/dist/replay/NativeReplay.js +0 -175
  42. package/dist/replay/NativeReplay.js.map +0 -1
  43. package/src/replay/NativeReplay.tsx +0 -240
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -68,6 +68,12 @@
68
68
  "import": "./dist/replay/index.js",
69
69
  "default": "./dist/replay/index.js"
70
70
  },
71
+ "./replay/demo": {
72
+ "source": "./src/replay/demo/index.ts",
73
+ "types": "./dist/replay/demo/index.d.ts",
74
+ "import": "./dist/replay/demo/index.js",
75
+ "default": "./dist/replay/demo/index.js"
76
+ },
71
77
  "./video": {
72
78
  "source": "./src/video/index.ts",
73
79
  "types": "./dist/video/index.d.ts",
@@ -113,6 +119,7 @@
113
119
  "lucide-react": "^1.17.0",
114
120
  "mermaid": "^11.15.0",
115
121
  "react-aria": "^3.52.1",
122
+ "rrweb": "2.0.0-alpha.20",
116
123
  "yaml": "^2.9.0"
117
124
  },
118
125
  "peerDependencies": {
@@ -120,7 +127,6 @@
120
127
  "@brett_lamy/viz-engine": ">=0.2.0",
121
128
  "react": ">=18",
122
129
  "react-dom": ">=18",
123
- "rrweb": "2.0.0-alpha.20",
124
130
  "vite": ">=5"
125
131
  },
126
132
  "peerDependenciesMeta": {
@@ -132,9 +138,6 @@
132
138
  },
133
139
  "vite": {
134
140
  "optional": true
135
- },
136
- "rrweb": {
137
- "optional": true
138
141
  }
139
142
  },
140
143
  "repository": {
@@ -148,7 +151,6 @@
148
151
  "@types/react": "^18.3.3",
149
152
  "@types/react-dom": "^18.3.7",
150
153
  "happy-dom": "^20.14.5",
151
- "rrweb": "2.0.0-alpha.20",
152
154
  "typescript": "^5.5.4",
153
155
  "vite": "^7.0.0",
154
156
  "vitest": "^3.2.4"
package/src/index.ts CHANGED
@@ -68,11 +68,12 @@ export type {
68
68
  } from "./demo"
69
69
  export { CitationSources, DocstreamRefContext, InlineReference } from "./docs/reference"
70
70
  export type { CitationPreview, CitationPreviewResolver, DocstreamRefContextValue } from "./docs/reference"
71
- export { ReplayEmbed, ReplayPreview } from "./replay/ReplayPreview"
71
+ export { ReplayEmbed, ReplayPreview, formatReplayTime } from "./replay/ReplayPreview"
72
72
  export type {
73
73
  ReplayEventsSource,
74
74
  ReplayEventsUrlSource,
75
75
  ReplayPageSource,
76
+ ReplayPreviewHandle,
76
77
  ReplayPreviewProps,
77
78
  ReplaySource,
78
79
  } from "./replay/ReplayPreview"
@@ -0,0 +1,430 @@
1
+ /* The player behind `ReplayPreview`: an rrweb session replay in a browser frame. The headless `ReplayStage`
2
+ plays the recording (rrweb loads lazily, on first render); this adds the chrome around it: a URL bar that
3
+ follows the recording's navigations, play/pause, a scrubber with the recording's moments on a rail above
4
+ it, a speed control, fullscreen, and a drawn cursor whose clicks ripple. Until the player is ready a
5
+ poster (a page-shaped skeleton by default) holds the frame's exact size, so nothing jumps.
6
+
7
+ Plain React + plain CSS (`@layer docstream.replay` in styles.css, `[data-slot]` hooks). Keys (focus inside
8
+ the player): Space / K play and pause, ← → seek 5 s, J / L 10 s, < > speed, F fullscreen, Home / End jump
9
+ to the ends. Everything drawn over the page is a function of the playhead, so scrubbing backwards replays
10
+ the ripples and the URL too. */
11
+ import {
12
+ memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
13
+ type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref,
14
+ } from 'react'
15
+ import { useReducedMotion } from 'framer-motion'
16
+ import { Lock, Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
17
+ import { ReplayStage } from './ReplayStage'
18
+ import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
19
+ import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
20
+ import type { ReplayStageHandle } from './stage-types'
21
+ import type { ReplayEvent } from './types'
22
+
23
+ const TRAFFIC_LIGHTS = ['#FF5F57', '#FEBC2E', '#28C840'] as const
24
+ export const DEFAULT_PLAYER_SPEEDS = [1, 2, 4, 0.5]
25
+ const RIPPLE_MS = 650
26
+
27
+ export type ReplayChrome = 'browser' | 'minimal' | 'none'
28
+
29
+ export interface ReplayPreviewHandle {
30
+ play(offset?: number): void
31
+ pause(offset?: number): void
32
+ /** Jump to `ms` from the start, keeping the play state. */
33
+ seek(ms: number): void
34
+ toggle(): void
35
+ getCurrentTime(): number
36
+ }
37
+
38
+ /** Options shared by every `ReplayPreview` (an `events` player or a `source` embed). */
39
+ export interface ReplayPlayerOptions {
40
+ /** Address shown in the URL bar (default: the recording's own URL, following its navigations). */
41
+ url?: string
42
+ /** Accessible name of the player (default "Session replay"). */
43
+ title?: string
44
+ /** `browser` (default): window buttons, URL bar, controls. `minimal`: the page and its controls in a rounded
45
+ * card. `none`: no frame, fill the parent (a hero, a lightbox). */
46
+ chrome?: ReplayChrome
47
+ /** macOS window buttons in the browser chrome (default true). */
48
+ trafficLights?: boolean
49
+ /** Play/pause, scrubber, speed and fullscreen (default true). */
50
+ controls?: boolean
51
+ autoplay?: boolean
52
+ loop?: boolean
53
+ /** Where the player starts, in ms from the start. */
54
+ initialTime?: number
55
+ /** Speeds the speed button cycles through; the first is the initial speed (default 1, 2, 4, 0.5). */
56
+ speeds?: number[]
57
+ /** Markers on the scrubber rail: `true` (default) reads them from the recording, `false` hides the rail,
58
+ * or pass your own. */
59
+ markers?: boolean | ReplayMarker[]
60
+ /** Which recorded marker kinds to show (default all). */
61
+ markerKinds?: ReplayMarkerKind[]
62
+ /** Draw the recorded pointer and click ripples (default true). */
63
+ cursor?: boolean
64
+ /** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
65
+ * height (give it one) and letterbox the page. */
66
+ fit?: 'width' | 'contain'
67
+ /** Shown until the player is ready (default: a page-shaped skeleton). */
68
+ poster?: ReactNode
69
+ /** Mount the player only once the frame nears the viewport (default true). */
70
+ lazy?: boolean
71
+ /** Imperative control (play, pause, seek). */
72
+ playerRef?: Ref<ReplayPreviewHandle | null>
73
+ onTimeUpdate?: (ms: number) => void
74
+ onPlayingChange?: (playing: boolean) => void
75
+ onMarkerPress?: (marker: ReplayMarker) => void
76
+ className?: string
77
+ style?: CSSProperties
78
+ }
79
+
80
+ export function formatReplayTime(ms: number) {
81
+ const s = Math.max(0, Math.floor(ms / 1000))
82
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
83
+ }
84
+
85
+ const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
86
+ click: 'Click', error: 'Error', network: 'Request failed', navigation: 'Navigation', custom: 'Checkpoint',
87
+ }
88
+
89
+ export function ReplayPlayer({
90
+ events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
91
+ autoplay = false, loop = false, initialTime = 0, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
92
+ cursor = true, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
93
+ className, style,
94
+ }: ReplayPlayerOptions & { events: ReplayEvent[] }) {
95
+ const rootRef = useRef<HTMLDivElement>(null)
96
+ const stage = useRef<ReplayStageHandle>(null)
97
+ const reduced = useReducedMotion()
98
+ const meta = useMemo(() => getReplayMeta(events), [events])
99
+ const track = useMemo(() => getReplayPointerTrack(events), [events])
100
+ const recorded = useMemo(() => getReplayMarkers(events), [events])
101
+ const shown = useMemo(() => {
102
+ const list = Array.isArray(markers) ? markers : markers ? recorded : []
103
+ return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
104
+ }, [markers, recorded, markerKinds])
105
+
106
+ const [visible, setVisible] = useState(!lazy)
107
+ const [ready, setReady] = useState(false)
108
+ const [posterGone, setPosterGone] = useState(false)
109
+ const [time, setTime] = useState(initialTime)
110
+ const [playing, setPlaying] = useState(false)
111
+ const [finished, setFinished] = useState(false)
112
+ const [speedIndex, setSpeedIndex] = useState(0)
113
+ const [fullscreen, setFullscreen] = useState(false)
114
+ const duration = meta.duration
115
+ const speedList = speeds.length ? speeds : DEFAULT_PLAYER_SPEEDS
116
+ const speed = speedList[speedIndex % speedList.length] ?? 1
117
+
118
+ // Lazy mount: start the player (and fetch rrweb) once the frame is near the viewport.
119
+ useEffect(() => {
120
+ if (visible) return
121
+ const el = rootRef.current
122
+ if (!el || typeof IntersectionObserver === 'undefined') return setVisible(true)
123
+ const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setVisible(true), { rootMargin: '240px' })
124
+ io.observe(el)
125
+ return () => io.disconnect()
126
+ }, [visible])
127
+
128
+ useEffect(() => {
129
+ const on = () => setFullscreen(document.fullscreenElement === rootRef.current)
130
+ document.addEventListener('fullscreenchange', on)
131
+ return () => document.removeEventListener('fullscreenchange', on)
132
+ }, [])
133
+
134
+ // The poster dissolves (CSS) once the page is ready, then leaves the tree.
135
+ useEffect(() => {
136
+ if (!ready) return setPosterGone(false)
137
+ const timer = setTimeout(() => setPosterGone(true), reduced ? 120 : 260)
138
+ return () => clearTimeout(timer)
139
+ }, [ready, reduced])
140
+
141
+ const handle = useMemo<ReplayPreviewHandle>(() => ({
142
+ play: (offset) => { setFinished(false); stage.current?.play(offset) },
143
+ pause: (offset) => stage.current?.pause(offset),
144
+ seek: (ms) => { setFinished(false); stage.current?.seek(ms) },
145
+ toggle: () => { setFinished(false); stage.current?.toggle() },
146
+ getCurrentTime: () => stage.current?.getCurrentTime() ?? 0,
147
+ }), [])
148
+ useImperativeHandle(playerRef, () => handle, [handle])
149
+
150
+ const onTime = useCallback((ms: number) => { setTime(ms); onTimeUpdate?.(ms) }, [onTimeUpdate])
151
+ const onPlaying = useCallback((p: boolean) => {
152
+ setPlaying(p)
153
+ if (p) setFinished(false)
154
+ onPlayingChange?.(p)
155
+ }, [onPlayingChange])
156
+
157
+ const toggle = () => handle.toggle()
158
+ const seekBy = (d: number) => handle.seek(Math.max(0, Math.min(duration, time + d)))
159
+ const cycleSpeed = (d = 1) => setSpeedIndex((i) => (i + d + speedList.length) % speedList.length)
160
+ const toggleFullscreen = () => {
161
+ const el = rootRef.current
162
+ if (!el) return
163
+ if (document.fullscreenElement) void document.exitFullscreen?.().catch(() => {})
164
+ else void el.requestFullscreen?.().catch(() => {})
165
+ }
166
+ const jumpTo = (m: ReplayMarker) => {
167
+ handle.seek(Math.max(0, m.time - 250))
168
+ onMarkerPress?.(m)
169
+ }
170
+
171
+ const onKeyDown = (e: ReactKeyboardEvent) => {
172
+ const target = e.target as HTMLElement
173
+ if (e.metaKey || e.ctrlKey || e.altKey || target.closest('input:not([type=range]), textarea, [contenteditable=true]')) return
174
+ const onSlider = target.matches('input[type=range]')
175
+ const run = (fn: () => void) => { e.preventDefault(); fn() }
176
+ switch (e.key) {
177
+ case ' ': case 'k': case 'K': if (target.closest('button') && e.key === ' ') return; return run(toggle)
178
+ case 'ArrowLeft': if (onSlider) return; return run(() => seekBy(-5000))
179
+ case 'ArrowRight': if (onSlider) return; return run(() => seekBy(5000))
180
+ case 'j': case 'J': return run(() => seekBy(-10000))
181
+ case 'l': case 'L': return run(() => seekBy(10000))
182
+ case 'Home': if (onSlider) return; return run(() => handle.seek(0))
183
+ case 'End': if (onSlider) return; return run(() => handle.seek(duration))
184
+ case '>': case '.': return run(() => cycleSpeed(1))
185
+ case '<': case ',': return run(() => cycleSpeed(-1))
186
+ case 'f': case 'F': return run(toggleFullscreen)
187
+ }
188
+ }
189
+
190
+ // The URL bar follows the recording: the latest navigation at or before the playhead.
191
+ const href = useMemo(() => {
192
+ if (url) return url
193
+ let current = meta.href ?? ''
194
+ for (const m of recorded) if (m.kind === 'navigation' && m.time <= time && m.detail) current = m.detail
195
+ return current
196
+ }, [url, meta.href, recorded, time])
197
+
198
+ const w = meta.width || 1280
199
+ const h = meta.height || 800
200
+ const contain = fit === 'contain' || fullscreen
201
+
202
+ return (
203
+ <div
204
+ ref={rootRef}
205
+ className={className ? `replay-preview ${className}` : 'replay-preview'}
206
+ data-slot="replay-preview"
207
+ data-replay-preview="rrweb"
208
+ data-chrome={chrome}
209
+ data-fit={contain ? 'contain' : 'width'}
210
+ data-state={!ready ? 'loading' : playing ? 'playing' : 'paused'}
211
+ data-fullscreen={fullscreen || undefined}
212
+ role="group"
213
+ aria-label={title}
214
+ aria-busy={!ready || undefined}
215
+ tabIndex={-1}
216
+ onKeyDown={onKeyDown}
217
+ // A click anywhere in the player (the page, the frame) makes its keys live, without a focus ring.
218
+ onPointerDown={() => {
219
+ const root = rootRef.current
220
+ if (root && !root.contains(document.activeElement)) root.focus({ preventScroll: true })
221
+ }}
222
+ style={style}
223
+ >
224
+ {chrome === 'browser' ? <BrowserBar href={href} trafficLights={trafficLights} loading={!ready && meta.playable} /> : null}
225
+
226
+ <div data-slot="replay-preview-stage" style={{ '--replay-ar': `${w} / ${h}` } as CSSProperties}>
227
+ {visible && meta.playable ? (
228
+ <ReplayStage
229
+ ref={stage}
230
+ events={events}
231
+ fit="contain"
232
+ speed={speed}
233
+ loop={loop}
234
+ autoplay={autoplay}
235
+ initialTime={initialTime}
236
+ showMouse={false}
237
+ className="replay-preview__page"
238
+ overlay={cursor ? <PointerOverlay track={track} time={time} reduced={!!reduced} /> : null}
239
+ onReady={() => setReady(true)}
240
+ onTimeUpdate={onTime}
241
+ onPlayingChange={onPlaying}
242
+ onFinish={() => setFinished(true)}
243
+ />
244
+ ) : null}
245
+
246
+ {!posterGone ? (
247
+ <div data-slot="replay-preview-poster" data-leaving={ready || undefined}>
248
+ {poster ?? <PagePoster empty={!meta.playable} />}
249
+ </div>
250
+ ) : null}
251
+
252
+ {controls ? (
253
+ <div data-slot="replay-preview-center" data-show={(ready && !playing) || undefined}>
254
+ <button
255
+ type="button"
256
+ tabIndex={-1}
257
+ aria-label={finished ? 'Replay from the start' : 'Play'}
258
+ // The overlay leaves as playback starts; keep focus (and the keys) in the player.
259
+ onClick={() => { rootRef.current?.focus({ preventScroll: true }); toggle() }}
260
+ >
261
+ {finished ? <RotateCcw size={22} strokeWidth={2.4} /> : <Play size={22} strokeWidth={2.4} fill="currentColor" />}
262
+ </button>
263
+ </div>
264
+ ) : null}
265
+ </div>
266
+
267
+ {controls ? (
268
+ <div data-slot="replay-preview-controls">
269
+ <button type="button" data-slot="replay-preview-play" aria-label={playing ? 'Pause' : 'Play'} onClick={toggle} disabled={!ready}>
270
+ {playing ? <Pause size={15} fill="currentColor" /> : <Play size={15} fill="currentColor" />}
271
+ </button>
272
+ <span data-slot="replay-preview-time">
273
+ <span>{formatReplayTime(time)}</span> / {formatReplayTime(duration)}
274
+ </span>
275
+ <Scrubber
276
+ time={time}
277
+ duration={duration}
278
+ markers={shown}
279
+ disabled={!ready}
280
+ onSeek={(ms) => handle.seek(ms)}
281
+ onMarker={jumpTo}
282
+ />
283
+ <button type="button" data-slot="replay-preview-speed" aria-label={`Playback speed ${speed}×`} onClick={() => cycleSpeed(1)}>
284
+ {`${speed}×`}
285
+ </button>
286
+ <button type="button" data-slot="replay-preview-fullscreen" aria-label={fullscreen ? 'Exit full screen' : 'Full screen'} onClick={toggleFullscreen}>
287
+ {fullscreen ? <Minimize size={15} /> : <Maximize size={15} />}
288
+ </button>
289
+ </div>
290
+ ) : null}
291
+ </div>
292
+ )
293
+ }
294
+
295
+ /* ── parts ───────────────────────────────────────────────────────────────────────── */
296
+
297
+ export function BrowserBar({ href, trafficLights, loading }: { href: string; trafficLights: boolean; loading: boolean }) {
298
+ const shown = href.replace(/^https?:\/\//, '')
299
+ const slash = shown.indexOf('/')
300
+ const host = slash < 0 ? shown : shown.slice(0, slash)
301
+ const path = slash < 0 ? '' : shown.slice(slash)
302
+ return (
303
+ <div data-slot="replay-preview-bar">
304
+ {trafficLights ? (
305
+ <span aria-hidden="true" data-slot="replay-preview-lights">
306
+ {TRAFFIC_LIGHTS.map((c) => <span key={c} style={{ '--light': c } as CSSProperties} />)}
307
+ </span>
308
+ ) : null}
309
+ <div data-slot="replay-preview-address">
310
+ <span data-slot="replay-preview-lock" aria-hidden="true">
311
+ {loading ? <span data-slot="replay-preview-spinner" /> : <Lock size={10} strokeWidth={2.5} />}
312
+ </span>
313
+ <span data-slot="replay-preview-url" title={href}>
314
+ <span>{host}</span>
315
+ <span>{path}</span>
316
+ </span>
317
+ </div>
318
+ {trafficLights ? <span aria-hidden="true" data-slot="replay-preview-lights-spacer" /> : null}
319
+ </div>
320
+ )
321
+ }
322
+
323
+ /** The recorded pointer and its click ripples, in recorded-viewport coordinates. */
324
+ const PointerOverlay = memo(function PointerOverlay({ track, time, reduced }: { track: ReplayPointerTrack; time: number; reduced: boolean }) {
325
+ const at = replayPointerAt(track, time)
326
+ const ripples = track.clicks.filter((c) => time >= c.time && time - c.time < RIPPLE_MS)
327
+ return (
328
+ <div data-slot="replay-preview-pointer" aria-hidden="true">
329
+ {ripples.map((c) => {
330
+ const p = (time - c.time) / RIPPLE_MS
331
+ const ease = 1 - (1 - p) ** 3
332
+ return (
333
+ <span
334
+ key={c.time}
335
+ data-slot="replay-preview-ripple"
336
+ style={{
337
+ left: c.x, top: c.y,
338
+ transform: `translate(-50%, -50%) scale(calc(${reduced ? 0.8 : 0.25 + ease * 1.05} / var(--replay-scale, 1)))`,
339
+ opacity: 1 - p,
340
+ }}
341
+ />
342
+ )
343
+ })}
344
+ {at ? (
345
+ <svg
346
+ data-slot="replay-preview-cursor"
347
+ viewBox="0 0 20 24" width="20" height="24"
348
+ style={{ left: at.x - 2, top: at.y - 1, transform: 'scale(calc(1 / var(--replay-scale, 1)))' }}
349
+ >
350
+ <path d="M2.5 1.5v18.2l4.7-4.5 3 6.8 3.1-1.4-3-6.6h6.5z" fill="#000" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
351
+ </svg>
352
+ ) : null}
353
+ </div>
354
+ )
355
+ })
356
+
357
+ function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
358
+ time: number; duration: number; markers: ReplayMarker[]; disabled: boolean
359
+ onSeek: (ms: number) => void; onMarker: (m: ReplayMarker) => void
360
+ }) {
361
+ const max = Math.max(1, duration)
362
+ const value = Math.min(time, max)
363
+ // Dots closer than ~1.5% of the timeline would sit on top of each other: step each one 10px right of the last.
364
+ const nudges = useMemo(() => {
365
+ const out = new Map<string, number>()
366
+ let last: { time: number; n: number } | null = null
367
+ for (const m of [...markers].sort((a, b) => a.time - b.time)) {
368
+ if (m.kind === 'click') continue
369
+ const n: number = last && m.time - last.time < max * 0.015 ? last.n + 1 : 0
370
+ out.set(m.id, n)
371
+ last = { time: m.time, n }
372
+ }
373
+ return out
374
+ }, [markers, max])
375
+ return (
376
+ <div data-slot="replay-preview-scrubber" data-markers={markers.length ? '' : undefined}>
377
+ {markers.length ? (
378
+ <div data-slot="replay-preview-markers">
379
+ {markers.map((m) => (
380
+ <button
381
+ key={m.id}
382
+ type="button"
383
+ data-kind={m.kind}
384
+ aria-label={`${m.label} at ${formatReplayTime(m.time)}`}
385
+ title={`${formatReplayTime(m.time)} · ${MARKER_NOUN[m.kind]}\n${m.detail ? `${m.label} — ${m.detail}` : m.label}`}
386
+ style={{ left: `calc(${(m.time / max) * 100}% + ${(nudges.get(m.id) ?? 0) * 10}px)` }}
387
+ onClick={() => onMarker(m)}
388
+ disabled={disabled}
389
+ />
390
+ ))}
391
+ </div>
392
+ ) : null}
393
+ <input
394
+ type="range"
395
+ aria-label="Playback position"
396
+ aria-valuetext={`${formatReplayTime(value)} of ${formatReplayTime(duration)}`}
397
+ min={0}
398
+ max={max}
399
+ step={10}
400
+ value={value}
401
+ disabled={disabled}
402
+ style={{ '--replay-progress': `${(value / max) * 100}%` } as CSSProperties}
403
+ onChange={(e) => onSeek(Number(e.currentTarget.value))}
404
+ />
405
+ </div>
406
+ )
407
+ }
408
+
409
+ /** A page-shaped placeholder: header, hero lines and a card grid, sized to the frame. */
410
+ function PagePoster({ empty }: { empty: boolean }) {
411
+ return (
412
+ <div data-slot="replay-preview-skeleton">
413
+ <div data-part="header">
414
+ <i style={{ width: '12%' }} />
415
+ <i style={{ width: '8%' }} />
416
+ <i style={{ width: '8%' }} />
417
+ <span />
418
+ <i data-tone="block" style={{ width: '9%' }} />
419
+ </div>
420
+ <div data-part="hero">
421
+ <i style={{ width: '38%', height: 12 }} />
422
+ <i style={{ width: '24%', height: 8 }} />
423
+ </div>
424
+ <div data-part="grid">
425
+ {[0, 1, 2, 3].map((i) => <i key={i} data-tone="block" />)}
426
+ </div>
427
+ {empty ? <span data-part="empty"><span>This recording has no page snapshot to play</span></span> : null}
428
+ </div>
429
+ )
430
+ }