svelte-p5-components 0.3.0 → 0.4.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.
@@ -0,0 +1,214 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import TimelineTrack, {
4
+ type TimelineSegment,
5
+ type TimelineViewWindow
6
+ } from './TimelineTrack.svelte';
7
+
8
+ // Full timeline widget: scrub track + play/pause + speed picker + time
9
+ // display. For apps that want the standard canvas-app timeline shape
10
+ // without reinventing controls.
11
+ //
12
+ // TimelineScrubber layers on top of <TimelineTrack> — reach for the track
13
+ // directly if you want custom chrome around it.
14
+ //
15
+ // Bindable: currentTime, isPlaying, speed, viewWindow. The consumer owns
16
+ // the animation loop that advances currentTime when isPlaying is true —
17
+ // this component just displays state and surfaces user intent.
18
+ //
19
+ // `speedLocked` (with an optional reason) is the UX-disclosure hook for
20
+ // video-driven playback, where the speed multiplier is effectively
21
+ // ignored. Set it to true and the speed button renders muted with a
22
+ // title-tip.
23
+ //
24
+ // See the package README for usage examples.
25
+
26
+ interface Props {
27
+ duration: number;
28
+ currentTime?: number;
29
+ isPlaying?: boolean;
30
+ speed?: number;
31
+ /** Available speed presets. Default: [1, 2, 4, 8, 16]. */
32
+ speedOptions?: number[];
33
+ /** Render speed as disabled / muted. Use when video playback overrides it. */
34
+ speedLocked?: boolean;
35
+ /** Title/tooltip shown when `speedLocked` is true. */
36
+ speedLockedReason?: string;
37
+ /** Optional zoomed view window; bindable. */
38
+ viewWindow?: TimelineViewWindow;
39
+ segments?: TimelineSegment[];
40
+ /** Format `currentTime` and `duration` for display. Default: mm:ss. */
41
+ formatTime?: (seconds: number) => string;
42
+ onSeek?: (time: number) => void;
43
+ onPlayToggle?: (nowPlaying: boolean) => void;
44
+ onSpeedChange?: (speed: number) => void;
45
+ onSegmentClick?: (segment: TimelineSegment) => void;
46
+ /** Snippet rendered above the hovered time, e.g. for a turn-preview tooltip. */
47
+ hoverIndicator?: Snippet<[{ time: number; left: number }]>;
48
+ class?: string;
49
+ }
50
+
51
+ let {
52
+ duration,
53
+ currentTime = $bindable(0),
54
+ isPlaying = $bindable(false),
55
+ speed = $bindable(1),
56
+ speedOptions = [1, 2, 4, 8, 16],
57
+ speedLocked = false,
58
+ speedLockedReason,
59
+ viewWindow = $bindable(undefined),
60
+ segments = [],
61
+ formatTime = defaultFormatTime,
62
+ onSeek,
63
+ onPlayToggle,
64
+ onSpeedChange,
65
+ onSegmentClick,
66
+ hoverIndicator,
67
+ class: className = ''
68
+ }: Props = $props();
69
+
70
+ function defaultFormatTime(seconds: number): string {
71
+ if (!isFinite(seconds)) return '00:00';
72
+ const m = Math.floor(seconds / 60);
73
+ const s = Math.floor(seconds % 60);
74
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
75
+ }
76
+
77
+ function togglePlay() {
78
+ isPlaying = !isPlaying;
79
+ onPlayToggle?.(isPlaying);
80
+ }
81
+
82
+ function cycleSpeed() {
83
+ if (speedLocked) return;
84
+ if (speedOptions.length === 0) return;
85
+ const idx = speedOptions.indexOf(speed);
86
+ const next = speedOptions[(idx + 1) % speedOptions.length] ?? speedOptions[0] ?? 1;
87
+ speed = next;
88
+ onSpeedChange?.(next);
89
+ }
90
+ </script>
91
+
92
+ <div class="timeline-scrubber {className}">
93
+ <div class="timeline-scrubber__track">
94
+ <span class="timeline-scrubber__time" aria-label="start"
95
+ >{formatTime(viewWindow?.start ?? 0)}</span
96
+ >
97
+ <TimelineTrack
98
+ {duration}
99
+ bind:currentTime
100
+ bind:viewWindow
101
+ {segments}
102
+ {onSeek}
103
+ {onSegmentClick}
104
+ {hoverIndicator}
105
+ />
106
+ <span class="timeline-scrubber__time" aria-label="end">
107
+ {formatTime(viewWindow?.end ?? duration)}
108
+ </span>
109
+ </div>
110
+
111
+ <div class="timeline-scrubber__controls">
112
+ <button
113
+ type="button"
114
+ class="timeline-scrubber__btn"
115
+ aria-pressed={isPlaying}
116
+ aria-label={isPlaying ? 'Pause' : 'Play'}
117
+ onclick={togglePlay}
118
+ >
119
+ {isPlaying ? '❚❚' : '▶'}
120
+ </button>
121
+
122
+ <button
123
+ type="button"
124
+ class="timeline-scrubber__btn timeline-scrubber__btn--speed"
125
+ class:is-locked={speedLocked}
126
+ aria-label="Playback speed: {speed}x{speedLocked
127
+ ? ` (locked${speedLockedReason ? ': ' + speedLockedReason : ''})`
128
+ : ''}"
129
+ title={speedLocked ? (speedLockedReason ?? 'Speed locked') : 'Click to change speed'}
130
+ disabled={speedLocked}
131
+ onclick={cycleSpeed}
132
+ >
133
+ {speed}×{speedLocked ? ' ·' : ''}
134
+ </button>
135
+
136
+ <span class="timeline-scrubber__currtime" aria-label="Current time">
137
+ {formatTime(currentTime)}
138
+ </span>
139
+ </div>
140
+ </div>
141
+
142
+ <style>
143
+ .timeline-scrubber {
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 4px;
147
+ font:
148
+ 12px/1 system-ui,
149
+ -apple-system,
150
+ Segoe UI,
151
+ sans-serif;
152
+ }
153
+
154
+ .timeline-scrubber__track {
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 8px;
158
+ }
159
+
160
+ .timeline-scrubber__track :global(.timeline-track) {
161
+ flex: 1 1 auto;
162
+ min-width: 0;
163
+ }
164
+
165
+ .timeline-scrubber__time {
166
+ font-family: ui-monospace, 'SF Mono', Menlo, monospace;
167
+ font-size: 11px;
168
+ color: #6b7280;
169
+ min-width: 3.5em;
170
+ text-align: center;
171
+ }
172
+
173
+ .timeline-scrubber__controls {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 8px;
177
+ justify-content: center;
178
+ }
179
+
180
+ .timeline-scrubber__btn {
181
+ background: #fff;
182
+ border: 1px solid #e5e7eb;
183
+ border-radius: 4px;
184
+ padding: 4px 10px;
185
+ cursor: pointer;
186
+ font: inherit;
187
+ line-height: 1;
188
+ }
189
+
190
+ .timeline-scrubber__btn:hover:not(:disabled) {
191
+ background: #f3f4f6;
192
+ }
193
+
194
+ .timeline-scrubber__btn:disabled {
195
+ cursor: not-allowed;
196
+ }
197
+
198
+ .timeline-scrubber__btn--speed {
199
+ min-width: 3em;
200
+ font-variant-numeric: tabular-nums;
201
+ }
202
+
203
+ .timeline-scrubber__btn--speed.is-locked {
204
+ opacity: 0.5;
205
+ }
206
+
207
+ .timeline-scrubber__currtime {
208
+ font-family: ui-monospace, 'SF Mono', Menlo, monospace;
209
+ font-size: 12px;
210
+ color: #111;
211
+ min-width: 4em;
212
+ text-align: right;
213
+ }
214
+ </style>
@@ -0,0 +1,32 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type TimelineSegment, type TimelineViewWindow } from './TimelineTrack.svelte';
3
+ interface Props {
4
+ duration: number;
5
+ currentTime?: number;
6
+ isPlaying?: boolean;
7
+ speed?: number;
8
+ /** Available speed presets. Default: [1, 2, 4, 8, 16]. */
9
+ speedOptions?: number[];
10
+ /** Render speed as disabled / muted. Use when video playback overrides it. */
11
+ speedLocked?: boolean;
12
+ /** Title/tooltip shown when `speedLocked` is true. */
13
+ speedLockedReason?: string;
14
+ /** Optional zoomed view window; bindable. */
15
+ viewWindow?: TimelineViewWindow;
16
+ segments?: TimelineSegment[];
17
+ /** Format `currentTime` and `duration` for display. Default: mm:ss. */
18
+ formatTime?: (seconds: number) => string;
19
+ onSeek?: (time: number) => void;
20
+ onPlayToggle?: (nowPlaying: boolean) => void;
21
+ onSpeedChange?: (speed: number) => void;
22
+ onSegmentClick?: (segment: TimelineSegment) => void;
23
+ /** Snippet rendered above the hovered time, e.g. for a turn-preview tooltip. */
24
+ hoverIndicator?: Snippet<[{
25
+ time: number;
26
+ left: number;
27
+ }]>;
28
+ class?: string;
29
+ }
30
+ declare const TimelineScrubber: import("svelte").Component<Props, {}, "currentTime" | "viewWindow" | "isPlaying" | "speed">;
31
+ type TimelineScrubber = ReturnType<typeof TimelineScrubber>;
32
+ export default TimelineScrubber;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import TimelineScrubber from './TimelineScrubber.svelte';
4
+ describe('<TimelineScrubber>', () => {
5
+ it('shows the play icon when paused and pause when playing', () => {
6
+ const { container: paused } = render(TimelineScrubber, {
7
+ props: { duration: 60, currentTime: 0, isPlaying: false }
8
+ });
9
+ expect(paused.querySelector('[aria-label="Play"]')).not.toBeNull();
10
+ const { container: playing } = render(TimelineScrubber, {
11
+ props: { duration: 60, currentTime: 0, isPlaying: true }
12
+ });
13
+ expect(playing.querySelector('[aria-label="Pause"]')).not.toBeNull();
14
+ });
15
+ it('clicking the play button fires onPlayToggle with the new state', async () => {
16
+ const onPlayToggle = vi.fn();
17
+ const { container } = render(TimelineScrubber, {
18
+ props: { duration: 60, isPlaying: false, onPlayToggle }
19
+ });
20
+ const btn = container.querySelector('[aria-label="Play"]');
21
+ await fireEvent.click(btn);
22
+ expect(onPlayToggle).toHaveBeenCalledWith(true);
23
+ });
24
+ it('cycles through the default speed presets on each speed-button click', async () => {
25
+ const onSpeedChange = vi.fn();
26
+ const { container } = render(TimelineScrubber, {
27
+ props: { duration: 60, speed: 1, onSpeedChange }
28
+ });
29
+ const btn = container.querySelector('.timeline-scrubber__btn--speed');
30
+ await fireEvent.click(btn);
31
+ expect(onSpeedChange).toHaveBeenLastCalledWith(2);
32
+ });
33
+ it('speedLocked disables the speed button and surfaces the reason via title', () => {
34
+ const { container } = render(TimelineScrubber, {
35
+ props: {
36
+ duration: 60,
37
+ speed: 8,
38
+ speedLocked: true,
39
+ speedLockedReason: 'Video is playing'
40
+ }
41
+ });
42
+ const btn = container.querySelector('.timeline-scrubber__btn--speed');
43
+ expect(btn.disabled).toBe(true);
44
+ expect(btn.classList.contains('is-locked')).toBe(true);
45
+ expect(btn.getAttribute('title')).toContain('Video is playing');
46
+ });
47
+ it('clicking a locked speed button does NOT emit onSpeedChange', async () => {
48
+ const onSpeedChange = vi.fn();
49
+ const { container } = render(TimelineScrubber, {
50
+ props: { duration: 60, speed: 2, speedLocked: true, onSpeedChange }
51
+ });
52
+ const btn = container.querySelector('.timeline-scrubber__btn--speed');
53
+ await fireEvent.click(btn);
54
+ expect(onSpeedChange).not.toHaveBeenCalled();
55
+ });
56
+ it('uses the custom formatTime prop for both edge labels and the current-time readout', () => {
57
+ const formatTime = (s) => `[${Math.round(s)}s]`;
58
+ const { container } = render(TimelineScrubber, {
59
+ props: { duration: 120, currentTime: 42, formatTime }
60
+ });
61
+ const text = container.textContent ?? '';
62
+ expect(text).toContain('[0s]');
63
+ expect(text).toContain('[120s]');
64
+ expect(text).toContain('[42s]');
65
+ });
66
+ });
@@ -0,0 +1,376 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * A named region on the timeline. Rendered as a tinted band; clicking
4
+ * it emits `onSegmentClick` with the full segment.
5
+ */
6
+ export interface TimelineSegment {
7
+ id: string;
8
+ start: number;
9
+ end: number;
10
+ label?: string;
11
+ color?: string;
12
+ }
13
+
14
+ export interface TimelineViewWindow {
15
+ start: number;
16
+ end: number;
17
+ }
18
+ </script>
19
+
20
+ <script lang="ts">
21
+ import type { Snippet } from 'svelte';
22
+ import { onDestroy } from 'svelte';
23
+
24
+ // Low-level timeline primitive: a horizontal track with a movable playhead,
25
+ // optional view window (zoom range with draggable handles), optional
26
+ // segment overlays, and seek/hover/segment-click event callbacks.
27
+ //
28
+ // This component is unopinionated about playback — it doesn't know how
29
+ // currentTime changes, only how to display it and how to collect user
30
+ // seek/hover input. Pair with <TimelineScrubber> (which adds play/pause
31
+ // and speed) or drive directly from your own animation loop.
32
+ //
33
+ // Units are arbitrary (seconds, frames, steps) — TimelineTrack just works
34
+ // in the same unit you pass in as `duration`.
35
+ //
36
+ // See the package README for usage examples.
37
+
38
+ interface Props {
39
+ /** Total timeline length in your chosen unit. */
40
+ duration: number;
41
+ /** Current playhead position. Bindable. */
42
+ currentTime?: number;
43
+ /**
44
+ * Optional zoomed view window. When set, only this range is visible on
45
+ * the track; clicks and hover map into this window. Bindable.
46
+ */
47
+ viewWindow?: TimelineViewWindow;
48
+ /** Highlighted regions rendered as labeled bands. */
49
+ segments?: TimelineSegment[];
50
+ /** Show draggable handles for the view window when it's set. Default: true. */
51
+ showViewWindowHandles?: boolean;
52
+ /** Show a vertical playhead line. Default: true. */
53
+ showPlayhead?: boolean;
54
+ /** Called when the user clicks or drags the playhead. */
55
+ onSeek?: (time: number) => void;
56
+ /** Called when the user drags a view window handle. */
57
+ onViewWindowChange?: (window: TimelineViewWindow) => void;
58
+ /** Called when the user clicks a segment band. */
59
+ onSegmentClick?: (segment: TimelineSegment) => void;
60
+ /** Called whenever the mouse moves over or leaves the track. `null` on leave. */
61
+ onHover?: (time: number | null) => void;
62
+ /** Snippet rendered above the hovered-time position. Use for preview tooltips. */
63
+ hoverIndicator?: Snippet<[{ time: number; left: number }]>;
64
+ class?: string;
65
+ }
66
+
67
+ let {
68
+ duration,
69
+ currentTime = $bindable(0),
70
+ viewWindow = $bindable(undefined),
71
+ segments = [],
72
+ showViewWindowHandles = true,
73
+ showPlayhead = true,
74
+ onSeek,
75
+ onViewWindowChange,
76
+ onSegmentClick,
77
+ onHover,
78
+ hoverIndicator,
79
+ class: className = ''
80
+ }: Props = $props();
81
+
82
+ let trackEl: HTMLDivElement | null = $state(null);
83
+ let dragging = $state<null | 'playhead' | 'window-start' | 'window-end'>(null);
84
+ let hoverX = $state<number | null>(null);
85
+
86
+ const effectiveStart = $derived(viewWindow?.start ?? 0);
87
+ const effectiveEnd = $derived(viewWindow?.end ?? duration);
88
+ const effectiveRange = $derived(Math.max(0.0001, effectiveEnd - effectiveStart));
89
+
90
+ function timeToPercent(t: number): number {
91
+ return ((t - effectiveStart) / effectiveRange) * 100;
92
+ }
93
+
94
+ function eventToTime(event: PointerEvent): number | null {
95
+ if (!trackEl) return null;
96
+ const rect = trackEl.getBoundingClientRect();
97
+ const x = event.clientX - rect.left;
98
+ const pct = Math.max(0, Math.min(1, x / rect.width));
99
+ return effectiveStart + pct * effectiveRange;
100
+ }
101
+
102
+ function eventToClientX(event: PointerEvent): number | null {
103
+ if (!trackEl) return null;
104
+ const rect = trackEl.getBoundingClientRect();
105
+ return Math.max(0, Math.min(rect.width, event.clientX - rect.left));
106
+ }
107
+
108
+ function handleTrackPointerDown(event: PointerEvent) {
109
+ // Ignore clicks on segment buttons / handles (they handle themselves).
110
+ if ((event.target as HTMLElement).closest('[data-skip-seek]')) return;
111
+
112
+ const time = eventToTime(event);
113
+ if (time === null) return;
114
+
115
+ currentTime = time;
116
+ onSeek?.(time);
117
+ dragging = 'playhead';
118
+ document.addEventListener('pointermove', handleDocPointerMove);
119
+ document.addEventListener('pointerup', handleDocPointerUp);
120
+ }
121
+
122
+ function handleDocPointerMove(event: PointerEvent) {
123
+ if (!dragging) return;
124
+ const time = eventToTime(event);
125
+ if (time === null) return;
126
+
127
+ if (dragging === 'playhead') {
128
+ currentTime = time;
129
+ onSeek?.(time);
130
+ } else if (dragging === 'window-start' && viewWindow) {
131
+ const next = {
132
+ start: Math.max(0, Math.min(viewWindow.end - 0.1, time)),
133
+ end: viewWindow.end
134
+ };
135
+ viewWindow = next;
136
+ onViewWindowChange?.(next);
137
+ } else if (dragging === 'window-end' && viewWindow) {
138
+ const next = {
139
+ start: viewWindow.start,
140
+ end: Math.min(duration, Math.max(viewWindow.start + 0.1, time))
141
+ };
142
+ viewWindow = next;
143
+ onViewWindowChange?.(next);
144
+ }
145
+ }
146
+
147
+ function handleDocPointerUp() {
148
+ dragging = null;
149
+ document.removeEventListener('pointermove', handleDocPointerMove);
150
+ document.removeEventListener('pointerup', handleDocPointerUp);
151
+ }
152
+
153
+ function handleHandlePointerDown(which: 'window-start' | 'window-end', event: PointerEvent) {
154
+ event.stopPropagation();
155
+ dragging = which;
156
+ document.addEventListener('pointermove', handleDocPointerMove);
157
+ document.addEventListener('pointerup', handleDocPointerUp);
158
+ }
159
+
160
+ function handleTrackPointerMove(event: PointerEvent) {
161
+ if (dragging) return;
162
+ const x = eventToClientX(event);
163
+ const time = eventToTime(event);
164
+ hoverX = x;
165
+ onHover?.(time);
166
+ }
167
+
168
+ function handleTrackPointerLeave() {
169
+ if (dragging) return;
170
+ hoverX = null;
171
+ onHover?.(null);
172
+ }
173
+
174
+ function handleSegmentClick(segment: TimelineSegment, event: MouseEvent) {
175
+ event.stopPropagation();
176
+ onSegmentClick?.(segment);
177
+ }
178
+
179
+ const hoveredTime = $derived.by(() => {
180
+ if (hoverX === null || !trackEl) return null;
181
+ const rect = trackEl.getBoundingClientRect();
182
+ const pct = Math.max(0, Math.min(1, hoverX / rect.width));
183
+ return effectiveStart + pct * effectiveRange;
184
+ });
185
+
186
+ onDestroy(() => {
187
+ if (typeof document !== 'undefined') {
188
+ document.removeEventListener('pointermove', handleDocPointerMove);
189
+ document.removeEventListener('pointerup', handleDocPointerUp);
190
+ }
191
+ });
192
+ </script>
193
+
194
+ <div
195
+ bind:this={trackEl}
196
+ class="timeline-track {className}"
197
+ class:is-dragging={dragging !== null}
198
+ onpointerdown={handleTrackPointerDown}
199
+ onpointermove={handleTrackPointerMove}
200
+ onpointerleave={handleTrackPointerLeave}
201
+ role="slider"
202
+ aria-valuemin={effectiveStart}
203
+ aria-valuemax={effectiveEnd}
204
+ aria-valuenow={currentTime}
205
+ tabindex="0"
206
+ >
207
+ <div class="timeline-track__rail"></div>
208
+
209
+ {#each segments as segment (segment.id)}
210
+ {@const left = timeToPercent(segment.start)}
211
+ {@const width = timeToPercent(segment.end) - left}
212
+ {#if width > 0 && left < 100 && left + width > 0}
213
+ <button
214
+ type="button"
215
+ data-skip-seek
216
+ class="timeline-track__segment"
217
+ style:left="{Math.max(0, left)}%"
218
+ style:width="{Math.min(100, left + width) - Math.max(0, left)}%"
219
+ style:background-color={segment.color ??
220
+ 'var(--timeline-segment-bg, rgba(59, 130, 246, 0.18))'}
221
+ title={segment.label ?? `${segment.start.toFixed(1)}–${segment.end.toFixed(1)}`}
222
+ onclick={(e) => handleSegmentClick(segment, e)}
223
+ >
224
+ {#if segment.label}<span class="timeline-track__segment-label">{segment.label}</span>{/if}
225
+ </button>
226
+ {/if}
227
+ {/each}
228
+
229
+ {#if viewWindow && showViewWindowHandles}
230
+ <div
231
+ data-skip-seek
232
+ class="timeline-track__handle timeline-track__handle--start"
233
+ style:left="0%"
234
+ onpointerdown={(e) => handleHandlePointerDown('window-start', e)}
235
+ role="slider"
236
+ aria-label="View window start"
237
+ aria-valuenow={viewWindow.start}
238
+ aria-valuemin={0}
239
+ aria-valuemax={viewWindow.end}
240
+ tabindex="0"
241
+ ></div>
242
+ <div
243
+ data-skip-seek
244
+ class="timeline-track__handle timeline-track__handle--end"
245
+ style:left="100%"
246
+ onpointerdown={(e) => handleHandlePointerDown('window-end', e)}
247
+ role="slider"
248
+ aria-label="View window end"
249
+ aria-valuenow={viewWindow.end}
250
+ aria-valuemin={viewWindow.start}
251
+ aria-valuemax={duration}
252
+ tabindex="0"
253
+ ></div>
254
+ {/if}
255
+
256
+ {#if showPlayhead}
257
+ <div
258
+ class="timeline-track__playhead"
259
+ style:left="{timeToPercent(currentTime)}%"
260
+ aria-hidden="true"
261
+ ></div>
262
+ {/if}
263
+
264
+ {#if hoverIndicator && hoveredTime !== null && hoverX !== null}
265
+ <div
266
+ class="timeline-track__hover-indicator"
267
+ style:left="{timeToPercent(hoveredTime)}%"
268
+ aria-hidden="true"
269
+ >
270
+ {@render hoverIndicator({ time: hoveredTime, left: hoverX })}
271
+ </div>
272
+ {/if}
273
+ </div>
274
+
275
+ <style>
276
+ .timeline-track {
277
+ position: relative;
278
+ width: 100%;
279
+ height: var(--timeline-track-height, 32px);
280
+ user-select: none;
281
+ touch-action: none;
282
+ cursor: pointer;
283
+
284
+ --timeline-rail-bg: #e5e7eb;
285
+ --timeline-rail-height: 6px;
286
+ --timeline-playhead-color: #ef4444;
287
+ --timeline-playhead-width: 2px;
288
+ --timeline-handle-color: #6b7280;
289
+ --timeline-handle-width: 4px;
290
+ }
291
+
292
+ .timeline-track:focus-visible {
293
+ outline: 2px solid #3b82f6;
294
+ outline-offset: 2px;
295
+ }
296
+
297
+ .timeline-track__rail {
298
+ position: absolute;
299
+ top: 50%;
300
+ left: 0;
301
+ right: 0;
302
+ height: var(--timeline-rail-height);
303
+ transform: translateY(-50%);
304
+ background: var(--timeline-rail-bg);
305
+ border-radius: calc(var(--timeline-rail-height) / 2);
306
+ pointer-events: none;
307
+ }
308
+
309
+ .timeline-track__segment {
310
+ position: absolute;
311
+ top: 50%;
312
+ transform: translateY(-50%);
313
+ height: calc(var(--timeline-rail-height) + 6px);
314
+ padding: 0 4px;
315
+ border: none;
316
+ border-radius: 3px;
317
+ cursor: pointer;
318
+ font:
319
+ 10px/1 system-ui,
320
+ sans-serif;
321
+ color: #1f2937;
322
+ overflow: hidden;
323
+ white-space: nowrap;
324
+ }
325
+
326
+ .timeline-track__segment:hover {
327
+ filter: brightness(0.92);
328
+ }
329
+
330
+ .timeline-track__segment-label {
331
+ opacity: 0.75;
332
+ }
333
+
334
+ .timeline-track__playhead {
335
+ position: absolute;
336
+ top: 15%;
337
+ bottom: 15%;
338
+ width: var(--timeline-playhead-width);
339
+ margin-left: calc(var(--timeline-playhead-width) / -2);
340
+ background: var(--timeline-playhead-color);
341
+ pointer-events: none;
342
+ border-radius: 1px;
343
+ }
344
+
345
+ .timeline-track__handle {
346
+ position: absolute;
347
+ top: 0;
348
+ bottom: 0;
349
+ width: 10px;
350
+ margin-left: -5px;
351
+ cursor: ew-resize;
352
+ display: flex;
353
+ align-items: center;
354
+ justify-content: center;
355
+ }
356
+
357
+ .timeline-track__handle::before {
358
+ content: '';
359
+ display: block;
360
+ width: var(--timeline-handle-width);
361
+ height: 100%;
362
+ background: var(--timeline-handle-color);
363
+ border-radius: 2px;
364
+ }
365
+
366
+ .timeline-track__handle:focus-visible {
367
+ outline: 2px solid #3b82f6;
368
+ outline-offset: 2px;
369
+ }
370
+
371
+ .timeline-track__hover-indicator {
372
+ position: absolute;
373
+ bottom: 100%;
374
+ pointer-events: none;
375
+ }
376
+ </style>