@lark-apaas/coding-registry-buildless 0.1.0 → 0.1.1

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,680 @@
1
+ /* BEGIN USAGE */
2
+ // animations.jsx
3
+ // Reusable animation starter: Stage, Timeline, Sprite, easing helpers.
4
+ // Exports (to window): Stage, Sprite, PlaybackBar, TextSprite, ImageSprite, RectSprite,
5
+ // useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp.
6
+ //
7
+ // Usage (in an HTML file that loads React + Babel):
8
+ //
9
+ // <Stage width={1280} height={720} duration={10} background="#f6f4ef">
10
+ // <MyScene />
11
+ // </Stage>
12
+ //
13
+ // <Stage> auto-scales to the viewport and provides the scrubber, play/pause,
14
+ // ←/→ seek, space, and 0-to-reset controls, and persists the playhead.
15
+ // Inside <Stage>, any child can call useTime() to read the current
16
+ // playhead (seconds). Or wrap content in <Sprite start={1} end={4}>...</Sprite>
17
+ // to only render during that window -- children receive a `localTime` and
18
+ // `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
19
+ // for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
20
+ // Build YOUR scenes by composing Sprites inside a Stage.
21
+ /* END USAGE */
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ // ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
25
+ // All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
26
+ const Easing = {
27
+ linear: (t) => t,
28
+
29
+ // Quad
30
+ easeInQuad: (t) => t * t,
31
+ easeOutQuad: (t) => t * (2 - t),
32
+ easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
33
+
34
+ // Cubic
35
+ easeInCubic: (t) => t * t * t,
36
+ easeOutCubic: (t) => (--t) * t * t + 1,
37
+ easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
38
+
39
+ // Quart
40
+ easeInQuart: (t) => t * t * t * t,
41
+ easeOutQuart: (t) => 1 - (--t) * t * t * t,
42
+ easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
43
+
44
+ // Expo
45
+ easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
46
+ easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
47
+ easeInOutExpo: (t) => {
48
+ if (t === 0) return 0;
49
+ if (t === 1) return 1;
50
+ if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
51
+ return 1 - 0.5 * Math.pow(2, -20 * t + 10);
52
+ },
53
+
54
+ // Sine
55
+ easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
56
+ easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
57
+ easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
58
+
59
+ // Back (overshoot)
60
+ easeOutBack: (t) => {
61
+ const c1 = 1.70158, c3 = c1 + 1;
62
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
63
+ },
64
+ easeInBack: (t) => {
65
+ const c1 = 1.70158, c3 = c1 + 1;
66
+ return c3 * t * t * t - c1 * t * t;
67
+ },
68
+ easeInOutBack: (t) => {
69
+ const c1 = 1.70158, c2 = c1 * 1.525;
70
+ return t < 0.5
71
+ ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
72
+ : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
73
+ },
74
+
75
+ // Elastic
76
+ easeOutElastic: (t) => {
77
+ const c4 = (2 * Math.PI) / 3;
78
+ if (t === 0) return 0;
79
+ if (t === 1) return 1;
80
+ return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
81
+ },
82
+ };
83
+
84
+ // ── Core interpolation helpers ──────────────────────────────────────────────
85
+
86
+ // Clamp a value to [min, max]
87
+ const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
88
+
89
+ // interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
90
+ // Popmotion-style: linearly maps t across input keyframes to output values,
91
+ // with optional easing per segment (single fn or array of fns).
92
+ function interpolate(input, output, ease = Easing.linear) {
93
+ return (t) => {
94
+ if (t <= input[0]) return output[0];
95
+ if (t >= input[input.length - 1]) return output[output.length - 1];
96
+ for (let i = 0; i < input.length - 1; i++) {
97
+ if (t >= input[i] && t <= input[i + 1]) {
98
+ const span = input[i + 1] - input[i];
99
+ const local = span === 0 ? 0 : (t - input[i]) / span;
100
+ const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
101
+ const eased = easeFn(local);
102
+ return output[i] + (output[i + 1] - output[i]) * eased;
103
+ }
104
+ }
105
+ return output[output.length - 1];
106
+ };
107
+ }
108
+
109
+ // animate({from, to, start, end, ease})(t) — simpler single-segment tween.
110
+ // Returns `from` before `start`, `to` after `end`.
111
+ function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
112
+ return (t) => {
113
+ if (t <= start) return from;
114
+ if (t >= end) return to;
115
+ const local = (t - start) / (end - start);
116
+ return from + (to - from) * ease(local);
117
+ };
118
+ }
119
+
120
+ // ── Timeline context ────────────────────────────────────────────────────────
121
+
122
+ const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
123
+
124
+ const useTime = () => React.useContext(TimelineContext).time;
125
+ const useTimeline = () => React.useContext(TimelineContext);
126
+
127
+ // ── Sprite ──────────────────────────────────────────────────────────────────
128
+ // Renders children only when the playhead is inside [start, end]. Provides
129
+ // a sub-context with `localTime` (seconds since start) and `progress` (0..1).
130
+ //
131
+ // <Sprite start={2} end={5}>
132
+ // {({ localTime, progress }) => <Thing x={progress * 100} />}
133
+ // </Sprite>
134
+ //
135
+ // Or as a plain wrapper — children can call useSprite() themselves.
136
+
137
+ const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
138
+ const useSprite = () => React.useContext(SpriteContext);
139
+
140
+ function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
141
+ const { time } = useTimeline();
142
+ const visible = time >= start && time <= end;
143
+ if (!visible && !keepMounted) return null;
144
+
145
+ const duration = end - start;
146
+ const localTime = Math.max(0, time - start);
147
+ const progress = duration > 0 && isFinite(duration)
148
+ ? clamp(localTime / duration, 0, 1)
149
+ : 0;
150
+
151
+ const value = { localTime, progress, duration, visible };
152
+
153
+ return (
154
+ <SpriteContext.Provider value={value}>
155
+ {typeof children === 'function' ? children(value) : children}
156
+ </SpriteContext.Provider>
157
+ );
158
+ }
159
+
160
+ // ── Sample sprite components ────────────────────────────────────────────────
161
+
162
+ // TextSprite: fades/slides text in on entry, holds, then fades out on exit.
163
+ // Props: text, x, y, size, color, font, entryDur, exitDur, align
164
+ function TextSprite({
165
+ text,
166
+ x = 0, y = 0,
167
+ size = 48,
168
+ color = '#111',
169
+ font = 'Inter, system-ui, sans-serif',
170
+ weight = 600,
171
+ entryDur = 0.45,
172
+ exitDur = 0.35,
173
+ entryEase = Easing.easeOutBack,
174
+ exitEase = Easing.easeInCubic,
175
+ align = 'left',
176
+ letterSpacing = '-0.01em',
177
+ }) {
178
+ const { localTime, duration } = useSprite();
179
+ const exitStart = Math.max(0, duration - exitDur);
180
+
181
+ let opacity = 1;
182
+ let ty = 0;
183
+
184
+ if (localTime < entryDur) {
185
+ const t = entryEase(clamp(localTime / entryDur, 0, 1));
186
+ opacity = t;
187
+ ty = (1 - t) * 16;
188
+ } else if (localTime > exitStart) {
189
+ const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
190
+ opacity = 1 - t;
191
+ ty = -t * 8;
192
+ }
193
+
194
+ const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';
195
+
196
+ return (
197
+ <div style={{
198
+ position: 'absolute',
199
+ left: x, top: y,
200
+ transform: `translate(${translateX}, ${ty}px)`,
201
+ opacity,
202
+ fontFamily: font,
203
+ fontSize: size,
204
+ fontWeight: weight,
205
+ color,
206
+ letterSpacing,
207
+ whiteSpace: 'pre',
208
+ lineHeight: 1.1,
209
+ willChange: 'transform, opacity',
210
+ }}>
211
+ {text}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ // ImageSprite: scales + fades in; optional Ken Burns drift during hold.
217
+ function ImageSprite({
218
+ src,
219
+ x = 0, y = 0,
220
+ width = 400, height = 300,
221
+ entryDur = 0.6,
222
+ exitDur = 0.4,
223
+ kenBurns = false,
224
+ kenBurnsScale = 1.08,
225
+ radius = 12,
226
+ fit = 'cover',
227
+ placeholder = null, // {label: string} for striped placeholder
228
+ }) {
229
+ const { localTime, duration } = useSprite();
230
+ const exitStart = Math.max(0, duration - exitDur);
231
+
232
+ let opacity = 1;
233
+ let scale = 1;
234
+
235
+ if (localTime < entryDur) {
236
+ const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
237
+ opacity = t;
238
+ scale = 0.96 + 0.04 * t;
239
+ } else if (localTime > exitStart) {
240
+ const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
241
+ opacity = 1 - t;
242
+ scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
243
+ } else if (kenBurns) {
244
+ const holdSpan = exitStart - entryDur;
245
+ const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
246
+ scale = 1 + (kenBurnsScale - 1) * holdT;
247
+ }
248
+
249
+ const content = placeholder ? (
250
+ <div style={{
251
+ width: '100%', height: '100%',
252
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
253
+ background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
254
+ color: '#6b6458',
255
+ fontFamily: 'JetBrains Mono, ui-monospace, monospace',
256
+ fontSize: 13,
257
+ letterSpacing: '0.04em',
258
+ textTransform: 'uppercase',
259
+ }}>
260
+ {placeholder.label || 'image'}
261
+ </div>
262
+ ) : (
263
+ <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
264
+ );
265
+
266
+ return (
267
+ <div style={{
268
+ position: 'absolute',
269
+ left: x, top: y,
270
+ width, height,
271
+ opacity,
272
+ transform: `scale(${scale})`,
273
+ transformOrigin: 'center',
274
+ borderRadius: radius,
275
+ overflow: 'hidden',
276
+ willChange: 'transform, opacity',
277
+ }}>
278
+ {content}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ // RectSprite: simple rectangle that animates position/size/color via props.
284
+ // Useful demo primitive — takes a `render` fn for per-frame customization.
285
+ function RectSprite({
286
+ x = 0, y = 0,
287
+ width = 100, height = 100,
288
+ color = '#111',
289
+ radius = 8,
290
+ entryDur = 0.4,
291
+ exitDur = 0.3,
292
+ render, // optional: (ctx) => style overrides
293
+ }) {
294
+ const spriteCtx = useSprite();
295
+ const { localTime, duration } = spriteCtx;
296
+ const exitStart = Math.max(0, duration - exitDur);
297
+
298
+ let opacity = 1;
299
+ let scale = 1;
300
+
301
+ if (localTime < entryDur) {
302
+ const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
303
+ opacity = clamp(localTime / entryDur, 0, 1);
304
+ scale = 0.4 + 0.6 * t;
305
+ } else if (localTime > exitStart) {
306
+ const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
307
+ opacity = 1 - t;
308
+ scale = 1 - 0.15 * t;
309
+ }
310
+
311
+ const overrides = render ? render(spriteCtx) : {};
312
+
313
+ return (
314
+ <div style={{
315
+ position: 'absolute',
316
+ left: x, top: y,
317
+ width, height,
318
+ background: color,
319
+ borderRadius: radius,
320
+ opacity,
321
+ transform: `scale(${scale})`,
322
+ transformOrigin: 'center',
323
+ willChange: 'transform, opacity',
324
+ ...overrides,
325
+ }} />
326
+ );
327
+ }
328
+
329
+
330
+ function Stage({
331
+ width = 1280,
332
+ height = 720,
333
+ duration = 10,
334
+ background = '#f6f4ef',
335
+ fps = 60,
336
+ loop = true,
337
+ autoplay = true,
338
+ persistKey = 'animstage',
339
+ children,
340
+ }) {
341
+ const [time, setTime] = React.useState(() => {
342
+ try {
343
+ const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
344
+ return isFinite(v) ? clamp(v, 0, duration) : 0;
345
+ } catch { return 0; }
346
+ });
347
+ const [playing, setPlaying] = React.useState(autoplay);
348
+ const [hoverTime, setHoverTime] = React.useState(null);
349
+ const [scale, setScale] = React.useState(1);
350
+
351
+ const stageRef = React.useRef(null);
352
+ const canvasRef = React.useRef(null);
353
+ const rafRef = React.useRef(null);
354
+ const lastTsRef = React.useRef(null);
355
+
356
+ // Persist playhead
357
+ React.useEffect(() => {
358
+ try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
359
+ }, [time, persistKey]);
360
+
361
+ // Auto-scale to fit viewport
362
+ React.useEffect(() => {
363
+ if (!stageRef.current) return;
364
+ const el = stageRef.current;
365
+ const measure = () => {
366
+ const barH = 44; // playback bar height
367
+ const s = Math.min(
368
+ el.clientWidth / width,
369
+ (el.clientHeight - barH) / height
370
+ );
371
+ setScale(Math.max(0.05, s));
372
+ };
373
+ measure();
374
+ const ro = new ResizeObserver(measure);
375
+ ro.observe(el);
376
+ window.addEventListener('resize', measure);
377
+ return () => {
378
+ ro.disconnect();
379
+ window.removeEventListener('resize', measure);
380
+ };
381
+ }, [width, height]);
382
+
383
+ // Animation loop
384
+ React.useEffect(() => {
385
+ if (!playing) {
386
+ lastTsRef.current = null;
387
+ return;
388
+ }
389
+ const step = (ts) => {
390
+ if (lastTsRef.current == null) lastTsRef.current = ts;
391
+ const dt = (ts - lastTsRef.current) / 1000;
392
+ lastTsRef.current = ts;
393
+ setTime((t) => {
394
+ let next = t + dt;
395
+ if (next >= duration) {
396
+ if (loop) next = next % duration;
397
+ else { next = duration; setPlaying(false); }
398
+ }
399
+ return next;
400
+ });
401
+ rafRef.current = requestAnimationFrame(step);
402
+ };
403
+ rafRef.current = requestAnimationFrame(step);
404
+ return () => {
405
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
406
+ lastTsRef.current = null;
407
+ };
408
+ }, [playing, duration, loop]);
409
+
410
+ // Keyboard: space = play/pause, ← → = seek
411
+ React.useEffect(() => {
412
+ const onKey = (e) => {
413
+ if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
414
+ if (e.code === 'Space') {
415
+ e.preventDefault();
416
+ setPlaying(p => !p);
417
+ } else if (e.code === 'ArrowLeft') {
418
+ setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
419
+ } else if (e.code === 'ArrowRight') {
420
+ setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
421
+ } else if (e.key === '0' || e.code === 'Home') {
422
+ setTime(0);
423
+ }
424
+ };
425
+ window.addEventListener('keydown', onKey);
426
+ return () => window.removeEventListener('keydown', onKey);
427
+ }, [duration]);
428
+
429
+ const displayTime = hoverTime != null ? hoverTime : time;
430
+
431
+ const ctxValue = React.useMemo(
432
+ () => ({ time: displayTime, duration, playing, setTime, setPlaying }),
433
+ [displayTime, duration, playing]
434
+ );
435
+
436
+ return (
437
+ <div
438
+ ref={stageRef}
439
+ style={{
440
+ position: 'absolute', inset: 0,
441
+ display: 'flex', flexDirection: 'column',
442
+ alignItems: 'center',
443
+ background: '#0a0a0a',
444
+ fontFamily: 'Inter, system-ui, sans-serif',
445
+ }}
446
+ >
447
+ {/* Canvas area — vertically centered in remaining space */}
448
+ <div style={{
449
+ flex: 1,
450
+ width: '100%',
451
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
452
+ overflow: 'hidden',
453
+ minHeight: 0,
454
+ }}>
455
+ <div
456
+ ref={canvasRef}
457
+ style={{
458
+ width, height,
459
+ background,
460
+ position: 'relative',
461
+ transform: `scale(${scale})`,
462
+ transformOrigin: 'center',
463
+ flexShrink: 0,
464
+ boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
465
+ overflow: 'hidden',
466
+ }}
467
+ >
468
+ <TimelineContext.Provider value={ctxValue}>
469
+ {children}
470
+ </TimelineContext.Provider>
471
+ </div>
472
+ </div>
473
+
474
+ {/* Playback bar — stacked below canvas, never overlapping */}
475
+ <PlaybackBar
476
+ time={displayTime}
477
+ actualTime={time}
478
+ duration={duration}
479
+ playing={playing}
480
+ onPlayPause={() => setPlaying(p => !p)}
481
+ onReset={() => { setTime(0); }}
482
+ onSeek={(t) => setTime(t)}
483
+ onHover={(t) => setHoverTime(t)}
484
+ />
485
+ </div>
486
+ );
487
+ }
488
+
489
+ // ── Playback bar ────────────────────────────────────────────────────────────
490
+ // Play/pause, return-to-begin, scrub track, time display.
491
+ // Uses fixed-width time fields so layout doesn't thrash.
492
+
493
+ function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek, onHover }) {
494
+ const trackRef = React.useRef(null);
495
+ const [dragging, setDragging] = React.useState(false);
496
+
497
+ const timeFromEvent = React.useCallback((e) => {
498
+ const rect = trackRef.current.getBoundingClientRect();
499
+ const x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
500
+ return x * duration;
501
+ }, [duration]);
502
+
503
+ const onTrackMove = (e) => {
504
+ if (!trackRef.current) return;
505
+ const t = timeFromEvent(e);
506
+ if (dragging) {
507
+ onSeek(t);
508
+ } else {
509
+ onHover(t);
510
+ }
511
+ };
512
+
513
+ const onTrackLeave = () => {
514
+ if (!dragging) onHover(null);
515
+ };
516
+
517
+ const onTrackDown = (e) => {
518
+ setDragging(true);
519
+ const t = timeFromEvent(e);
520
+ onSeek(t);
521
+ onHover(null);
522
+ };
523
+
524
+ React.useEffect(() => {
525
+ if (!dragging) return;
526
+ const onUp = () => setDragging(false);
527
+ const onMove = (e) => {
528
+ if (!trackRef.current) return;
529
+ const t = timeFromEvent(e);
530
+ onSeek(t);
531
+ };
532
+ window.addEventListener('mouseup', onUp);
533
+ window.addEventListener('mousemove', onMove);
534
+ return () => {
535
+ window.removeEventListener('mouseup', onUp);
536
+ window.removeEventListener('mousemove', onMove);
537
+ };
538
+ }, [dragging, timeFromEvent, onSeek]);
539
+
540
+ const pct = duration > 0 ? (time / duration) * 100 : 0;
541
+ const fmt = (t) => {
542
+ const total = Math.max(0, t);
543
+ const m = Math.floor(total / 60);
544
+ const s = Math.floor(total % 60);
545
+ const cs = Math.floor((total * 100) % 100);
546
+ return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
547
+ };
548
+
549
+ const mono = 'JetBrains Mono, ui-monospace, SFMono-Regular, monospace';
550
+
551
+ return (
552
+ <div style={{
553
+ display: 'flex', alignItems: 'center', gap: 12,
554
+ padding: '8px 16px',
555
+ background: 'rgba(20,20,20,0.92)',
556
+ borderTop: '1px solid rgba(255,255,255,0.08)',
557
+ width: '100%',
558
+ maxWidth: 680,
559
+ alignSelf: 'center',
560
+
561
+ borderRadius: 8,
562
+ color: '#f6f4ef',
563
+ fontFamily: 'Inter, system-ui, sans-serif',
564
+ userSelect: 'none',
565
+ flexShrink: 0,
566
+ }}>
567
+ <IconButton onClick={onReset} title="Return to start (0)">
568
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
569
+ <path d="M3 2v10M12 2L5 7l7 5V2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
570
+ </svg>
571
+ </IconButton>
572
+ <IconButton onClick={onPlayPause} title="Play/pause (space)">
573
+ {playing ? (
574
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
575
+ <rect x="3" y="2" width="3" height="10" fill="currentColor"/>
576
+ <rect x="8" y="2" width="3" height="10" fill="currentColor"/>
577
+ </svg>
578
+ ) : (
579
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
580
+ <path d="M3 2l9 5-9 5V2z" fill="currentColor"/>
581
+ </svg>
582
+ )}
583
+ </IconButton>
584
+
585
+ {/* Current time: fixed width so it doesn't thrash */}
586
+ <div style={{
587
+ fontFamily: mono,
588
+ fontSize: 12,
589
+ fontVariantNumeric: 'tabular-nums',
590
+ width: 64, textAlign: 'right',
591
+ color: '#f6f4ef',
592
+ }}>
593
+ {fmt(time)}
594
+ </div>
595
+
596
+ {/* Scrub track */}
597
+ <div
598
+ ref={trackRef}
599
+ onMouseMove={onTrackMove}
600
+ onMouseLeave={onTrackLeave}
601
+ onMouseDown={onTrackDown}
602
+ style={{
603
+ flex: 1,
604
+ height: 22,
605
+ position: 'relative',
606
+ cursor: 'pointer',
607
+ display: 'flex', alignItems: 'center',
608
+ }}
609
+ >
610
+ <div style={{
611
+ position: 'absolute',
612
+ left: 0, right: 0, height: 4,
613
+ background: 'rgba(255,255,255,0.12)',
614
+ borderRadius: 2,
615
+ }}/>
616
+ <div style={{
617
+ position: 'absolute',
618
+ left: 0, width: `${pct}%`, height: 4,
619
+ background: 'oklch(72% 0.12 250)',
620
+ borderRadius: 2,
621
+ }}/>
622
+ <div style={{
623
+ position: 'absolute',
624
+ left: `${pct}%`, top: '50%',
625
+ width: 12, height: 12,
626
+ marginLeft: -6, marginTop: -6,
627
+ background: '#fff',
628
+ borderRadius: 6,
629
+ boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
630
+ }}/>
631
+ </div>
632
+
633
+ {/* Duration: fixed width */}
634
+ <div style={{
635
+ fontFamily: mono,
636
+ fontSize: 12,
637
+ fontVariantNumeric: 'tabular-nums',
638
+ width: 64, textAlign: 'left',
639
+ color: 'rgba(246,244,239,0.55)',
640
+ }}>
641
+ {fmt(duration)}
642
+ </div>
643
+ </div>
644
+ );
645
+ }
646
+
647
+ function IconButton({ children, onClick, title }) {
648
+ const [hover, setHover] = React.useState(false);
649
+ return (
650
+ <button
651
+ onClick={onClick}
652
+ title={title}
653
+ onMouseEnter={() => setHover(true)}
654
+ onMouseLeave={() => setHover(false)}
655
+ style={{
656
+ width: 28, height: 28,
657
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
658
+ background: hover ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.04)',
659
+ border: '1px solid rgba(255,255,255,0.1)',
660
+ borderRadius: 6,
661
+ color: '#f6f4ef',
662
+ cursor: 'pointer',
663
+ padding: 0,
664
+ transition: 'background 120ms',
665
+ }}
666
+ >
667
+ {children}
668
+ </button>
669
+ );
670
+ }
671
+
672
+
673
+ Object.assign(window, {
674
+ Easing, interpolate, animate, clamp,
675
+ TimelineContext, useTime, useTimeline,
676
+ Sprite, SpriteContext, useSprite,
677
+ TextSprite, ImageSprite, RectSprite,
678
+ Stage, PlaybackBar,
679
+ });
680
+