@hypertools/sdk 0.3.2 → 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,483 @@
1
+ import { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { ExperienceController } from '@hypertools/sdk';
3
+ import type { ExportedExperienceElement } from '@hypertools/sdk';
4
+ import './App.css';
5
+
6
+ /**
7
+ * React Landing Page with HyperTools Experience Background
8
+ *
9
+ * This example shows how to:
10
+ * 1. Use an exported HyperTools experience as a background
11
+ * 2. Control it programmatically via ExperienceController from @hypertools/sdk
12
+ * 3. Build CUSTOM features on top that the platform doesn't provide
13
+ */
14
+
15
+ // === CUSTOM FEATURE: Preset configurations ===
16
+ const PRESETS = {
17
+ default: { birdCount: 810, rainbowMode: true, displayText: 'HYPERTOOLS' },
18
+ calm: { birdCount: 100, rainbowMode: false, displayText: 'ZEN' },
19
+ party: { birdCount: 800, rainbowMode: true, displayText: 'PARTY!' },
20
+ chaos: { birdCount: 1000, rainbowMode: true, displayText: 'CHAOS' },
21
+ minimal: { birdCount: 50, rainbowMode: false, displayText: '' },
22
+ };
23
+
24
+ type PresetName = keyof typeof PRESETS;
25
+
26
+ // === CUSTOM FEATURE: Time-based themes ===
27
+ const getTimeBasedTheme = () => {
28
+ const hour = new Date().getHours();
29
+ if (hour >= 6 && hour < 12) return { name: 'Morning', displayText: 'RISE & SHINE' };
30
+ if (hour >= 12 && hour < 17) return { name: 'Afternoon', displayText: 'PRODUCTIVE' };
31
+ if (hour >= 17 && hour < 21) return { name: 'Evening', displayText: 'GOLDEN HOUR' };
32
+ return { name: 'Night', displayText: 'DREAM MODE' };
33
+ };
34
+
35
+ function App() {
36
+ const experienceRef = useRef<ExportedExperienceElement>(null);
37
+ const controllerRef = useRef<ExperienceController | null>(null);
38
+
39
+ // Track some params for the UI
40
+ const [birdCount, setBirdCount] = useState(810);
41
+ const [rainbowMode, setRainbowMode] = useState(true);
42
+ const [displayText, setDisplayText] = useState('HYPERTOOLS');
43
+ const [isReady, setIsReady] = useState(false);
44
+
45
+ // === CUSTOM FEATURE: Selected preset tracking ===
46
+ const [selectedPreset, setSelectedPreset] = useState<PresetName | null>('default');
47
+
48
+ // === CUSTOM FEATURE: Auto-pilot mode ===
49
+ const [autoPilot, setAutoPilot] = useState(false);
50
+ const autoPilotRef = useRef<number | null>(null);
51
+
52
+ // === CUSTOM FEATURE: Keyboard shortcuts ===
53
+ const [showShortcuts, setShowShortcuts] = useState(false);
54
+
55
+ // === CUSTOM FEATURE: Click-to-form mode ===
56
+ const [clickToForm, setClickToForm] = useState(false);
57
+
58
+ // === CUSTOM FEATURE: Idle screensaver ===
59
+ const [idleMode, setIdleMode] = useState(false);
60
+ const idleTimerRef = useRef<number | null>(null);
61
+ const IDLE_TIMEOUT = 10000; // 10 seconds of inactivity
62
+
63
+ // === Check if current values match any preset ===
64
+ const detectCurrentPreset = useCallback((): PresetName | null => {
65
+ for (const [name, preset] of Object.entries(PRESETS)) {
66
+ if (
67
+ preset.birdCount === birdCount &&
68
+ preset.rainbowMode === rainbowMode &&
69
+ preset.displayText === displayText
70
+ ) {
71
+ return name as PresetName;
72
+ }
73
+ }
74
+ return null; // Custom configuration
75
+ }, [birdCount, rainbowMode, displayText]);
76
+
77
+ // Update selected preset when values change
78
+ useEffect(() => {
79
+ setSelectedPreset(detectCurrentPreset());
80
+ }, [detectCurrentPreset]);
81
+
82
+ // Connect SDK controller when component mounts
83
+ useEffect(() => {
84
+ const element = experienceRef.current;
85
+ if (!element) return;
86
+
87
+ const onReady = () => {
88
+ // Connect ExperienceController from @hypertools/sdk
89
+ const controller = new ExperienceController({
90
+ element,
91
+ initialParams: {
92
+ birdCount: 810,
93
+ rainbowMode: true,
94
+ displayText: 'HYPERTOOLS',
95
+ },
96
+ });
97
+
98
+ // Listen to param changes
99
+ controller.on('paramChange', (event) => {
100
+ console.log('Param changed:', event.key, '=', event.value);
101
+ });
102
+
103
+ controllerRef.current = controller;
104
+ setIsReady(true);
105
+ console.log('ExperienceController connected!');
106
+ console.log('Available params:', controller.getParamDefs());
107
+ };
108
+
109
+ // Check if already ready or wait for ready event
110
+ if ((element as any)._cleanup) {
111
+ onReady();
112
+ } else {
113
+ element.addEventListener('ready', onReady, { once: true });
114
+ }
115
+
116
+ return () => {
117
+ element.removeEventListener('ready', onReady);
118
+ controllerRef.current?.destroy();
119
+ };
120
+ }, []);
121
+
122
+ // Handlers for UI controls
123
+ const handleBirdCountChange = (value: number) => {
124
+ setBirdCount(value);
125
+ controllerRef.current?.setParam('birdCount', value);
126
+ };
127
+
128
+ const handleRainbowToggle = () => {
129
+ const newValue = !rainbowMode;
130
+ setRainbowMode(newValue);
131
+ controllerRef.current?.setParam('rainbowMode', newValue);
132
+ };
133
+
134
+ const handleTextChange = (value: string) => {
135
+ setDisplayText(value);
136
+ controllerRef.current?.setParam('displayText', value);
137
+ };
138
+
139
+ const handleReset = () => {
140
+ controllerRef.current?.resetParams();
141
+ // Sync local state with defaults
142
+ const defs = controllerRef.current?.getParamDefs();
143
+ if (defs) {
144
+ setBirdCount(defs.birdCount?.value as number ?? 100);
145
+ setRainbowMode(defs.rainbowMode?.value as boolean ?? false);
146
+ setDisplayText(defs.displayText?.value as string ?? '');
147
+ }
148
+ };
149
+
150
+ // Trigger formation by dispatching synthetic mouse events
151
+ const handleTriggerFormation = useCallback(() => {
152
+ const controller = controllerRef.current;
153
+ if (!controller) return;
154
+
155
+ // Simulate long press: mousedown -> wait 600ms -> mouseup
156
+ controller.dispatchToCanvas('mousedown', { clientX: 400, clientY: 300 });
157
+ setTimeout(() => {
158
+ controller.dispatchToCanvas('mouseup', { clientX: 400, clientY: 300 });
159
+ }, 600);
160
+ }, []);
161
+
162
+ // === CUSTOM FEATURE: Apply preset ===
163
+ const applyPreset = useCallback((presetName: PresetName) => {
164
+ const preset = PRESETS[presetName];
165
+ controllerRef.current?.setParams(preset);
166
+ setBirdCount(preset.birdCount);
167
+ setRainbowMode(preset.rainbowMode);
168
+ setDisplayText(preset.displayText);
169
+ setSelectedPreset(presetName);
170
+ }, []);
171
+
172
+ // === CUSTOM FEATURE: Reset to default ===
173
+ const handleResetToDefault = useCallback(() => {
174
+ applyPreset('default');
175
+ }, [applyPreset]);
176
+
177
+ // === CUSTOM FEATURE: Share configuration URL ===
178
+ const handleShareConfig = useCallback(() => {
179
+ const config = { birdCount, rainbowMode, displayText };
180
+ const params = new URLSearchParams({
181
+ config: btoa(JSON.stringify(config))
182
+ });
183
+ const url = `${window.location.origin}${window.location.pathname}?${params}`;
184
+ navigator.clipboard.writeText(url);
185
+ alert('Configuration URL copied to clipboard!');
186
+ }, [birdCount, rainbowMode, displayText]);
187
+
188
+ // === CUSTOM FEATURE: Load config from URL on mount ===
189
+ useEffect(() => {
190
+ const params = new URLSearchParams(window.location.search);
191
+ const configParam = params.get('config');
192
+ if (configParam && isReady) {
193
+ try {
194
+ const config = JSON.parse(atob(configParam));
195
+ if (config.birdCount) handleBirdCountChange(config.birdCount);
196
+ if (config.rainbowMode !== undefined) {
197
+ setRainbowMode(config.rainbowMode);
198
+ controllerRef.current?.setParam('rainbowMode', config.rainbowMode);
199
+ }
200
+ if (config.displayText !== undefined) handleTextChange(config.displayText);
201
+ } catch (e) {
202
+ console.warn('Invalid config in URL');
203
+ }
204
+ }
205
+ }, [isReady]);
206
+
207
+ // === CUSTOM FEATURE: Apply time-based theme ===
208
+ const applyTimeTheme = useCallback(() => {
209
+ const theme = getTimeBasedTheme();
210
+ handleTextChange(theme.displayText);
211
+ }, []);
212
+
213
+ // === CUSTOM FEATURE: Auto-pilot cycling through presets ===
214
+ useEffect(() => {
215
+ if (!autoPilot || !isReady) {
216
+ if (autoPilotRef.current) {
217
+ clearInterval(autoPilotRef.current);
218
+ autoPilotRef.current = null;
219
+ }
220
+ return;
221
+ }
222
+
223
+ const presetNames = Object.keys(PRESETS) as (keyof typeof PRESETS)[];
224
+ let index = 0;
225
+
226
+ const cycle = () => {
227
+ applyPreset(presetNames[index]);
228
+ // Trigger formation on each cycle
229
+ setTimeout(handleTriggerFormation, 500);
230
+ index = (index + 1) % presetNames.length;
231
+ };
232
+
233
+ cycle(); // Apply first preset immediately
234
+ autoPilotRef.current = window.setInterval(cycle, 4000);
235
+
236
+ return () => {
237
+ if (autoPilotRef.current) {
238
+ clearInterval(autoPilotRef.current);
239
+ }
240
+ };
241
+ }, [autoPilot, isReady, applyPreset, handleTriggerFormation]);
242
+
243
+ // === CUSTOM FEATURE: Idle screensaver mode ===
244
+ useEffect(() => {
245
+ const resetIdleTimer = () => {
246
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
247
+ if (idleMode) {
248
+ setIdleMode(false);
249
+ setAutoPilot(false);
250
+ }
251
+ idleTimerRef.current = window.setTimeout(() => {
252
+ setIdleMode(true);
253
+ setAutoPilot(true); // Start auto-pilot when idle
254
+ }, IDLE_TIMEOUT);
255
+ };
256
+
257
+ const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
258
+ events.forEach((event) => window.addEventListener(event, resetIdleTimer));
259
+ resetIdleTimer();
260
+
261
+ return () => {
262
+ events.forEach((event) => window.removeEventListener(event, resetIdleTimer));
263
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
264
+ };
265
+ }, [idleMode, IDLE_TIMEOUT]);
266
+
267
+ // === CUSTOM FEATURE: Click-to-form (click anywhere to trigger formation at that position) ===
268
+ useEffect(() => {
269
+ if (!clickToForm || !isReady) return;
270
+
271
+ const handleClick = (e: MouseEvent) => {
272
+ // Ignore clicks on the control panel
273
+ if ((e.target as HTMLElement).closest('.control-panel')) return;
274
+
275
+ const controller = controllerRef.current;
276
+ if (!controller) return;
277
+
278
+ // Trigger formation at click position
279
+ controller.dispatchToCanvas('mousedown', { clientX: e.clientX, clientY: e.clientY });
280
+ setTimeout(() => {
281
+ controller.dispatchToCanvas('mouseup', { clientX: e.clientX, clientY: e.clientY });
282
+ }, 600);
283
+ };
284
+
285
+ window.addEventListener('click', handleClick);
286
+ return () => window.removeEventListener('click', handleClick);
287
+ }, [clickToForm, isReady]);
288
+
289
+ // === CUSTOM FEATURE: Keyboard shortcuts ===
290
+ useEffect(() => {
291
+ const handleKeyDown = (e: KeyboardEvent) => {
292
+ if (!isReady) return;
293
+ // Ignore if typing in input
294
+ if (e.target instanceof HTMLInputElement) return;
295
+
296
+ switch (e.key.toLowerCase()) {
297
+ case ' ':
298
+ e.preventDefault();
299
+ handleTriggerFormation();
300
+ break;
301
+ case 'arrowup':
302
+ e.preventDefault();
303
+ handleBirdCountChange(Math.min(birdCount + 50, 1000));
304
+ break;
305
+ case 'arrowdown':
306
+ e.preventDefault();
307
+ handleBirdCountChange(Math.max(birdCount - 50, 10));
308
+ break;
309
+ case 'r':
310
+ setRainbowMode((prev) => {
311
+ const newValue = !prev;
312
+ controllerRef.current?.setParam('rainbowMode', newValue);
313
+ return newValue;
314
+ });
315
+ break;
316
+ case 's':
317
+ handleShareConfig();
318
+ break;
319
+ case 'a':
320
+ setAutoPilot((prev) => !prev);
321
+ break;
322
+ case '1':
323
+ applyPreset('calm');
324
+ break;
325
+ case '2':
326
+ applyPreset('party');
327
+ break;
328
+ case '3':
329
+ applyPreset('chaos');
330
+ break;
331
+ case '4':
332
+ applyPreset('minimal');
333
+ break;
334
+ case '?':
335
+ setShowShortcuts((prev) => !prev);
336
+ break;
337
+ }
338
+ };
339
+
340
+ window.addEventListener('keydown', handleKeyDown);
341
+ return () => window.removeEventListener('keydown', handleKeyDown);
342
+ }, [isReady, birdCount, handleTriggerFormation, handleShareConfig, applyPreset]);
343
+
344
+ return (
345
+ <div className="landing-page">
346
+ {/* Background: Exported HyperTools Experience */}
347
+ <div className="experience-background">
348
+ {/* @ts-expect-error - Custom element */}
349
+ <boids-flocking-project ref={experienceRef} />
350
+ </div>
351
+
352
+ {/* Foreground: Control Panel at bottom center */}
353
+ <div className="landing-content">
354
+ {!isReady && <p className="loading">Loading experience...</p>}
355
+
356
+ {/* Demo: Programmatic control via ExperienceController */}
357
+ <section className="control-panel">
358
+ <div className="panel-header">
359
+ <h2>Control the Background</h2>
360
+ <button className="help-button" onClick={() => setShowShortcuts(true)}>?</button>
361
+ </div>
362
+ <p className="sdk-info">Custom features built with @hypertools/sdk</p>
363
+
364
+ {/* === CUSTOM FEATURE: Preset buttons with selection indicator === */}
365
+ <div className="preset-group">
366
+ {(Object.keys(PRESETS) as PresetName[]).map((preset) => (
367
+ <button
368
+ key={preset}
369
+ className={`preset-button ${selectedPreset === preset ? 'selected' : ''}`}
370
+ onClick={() => applyPreset(preset)}
371
+ >
372
+ {preset}
373
+ </button>
374
+ ))}
375
+ </div>
376
+ {selectedPreset === null && (
377
+ <p className="custom-config-hint">Custom configuration</p>
378
+ )}
379
+
380
+ <div className="control-group">
381
+ <label>
382
+ Bird Count: {birdCount}
383
+ <input
384
+ type="range"
385
+ min={10}
386
+ max={1000}
387
+ step={10}
388
+ value={birdCount}
389
+ onChange={(e) => handleBirdCountChange(Number(e.target.value))}
390
+ />
391
+ </label>
392
+ </div>
393
+
394
+ <div className="control-group">
395
+ <label>
396
+ <input
397
+ type="checkbox"
398
+ checked={rainbowMode}
399
+ onChange={handleRainbowToggle}
400
+ />
401
+ Rainbow Mode
402
+ </label>
403
+ </div>
404
+
405
+ <div className="control-group">
406
+ <label>
407
+ Display Text:
408
+ <input
409
+ type="text"
410
+ value={displayText}
411
+ onChange={(e) => handleTextChange(e.target.value)}
412
+ />
413
+ </label>
414
+ </div>
415
+
416
+ {/* === CUSTOM FEATURES: Action buttons === */}
417
+ <div className="button-group">
418
+ <button className="action-button" onClick={handleTriggerFormation}>
419
+ Formation
420
+ </button>
421
+ <button
422
+ className={`action-button ${clickToForm ? 'active' : ''}`}
423
+ onClick={() => setClickToForm(!clickToForm)}
424
+ title="Click anywhere to trigger formation at that position"
425
+ >
426
+ {clickToForm ? 'Click: ON' : 'Click Mode'}
427
+ </button>
428
+ </div>
429
+
430
+ <div className="button-group">
431
+ <button
432
+ className={`action-button ${autoPilot ? 'active' : ''}`}
433
+ onClick={() => setAutoPilot(!autoPilot)}
434
+ >
435
+ {autoPilot ? 'Stop Auto' : 'Auto-pilot'}
436
+ </button>
437
+ <button className="action-button" onClick={applyTimeTheme}>
438
+ Time Theme
439
+ </button>
440
+ </div>
441
+
442
+ <div className="button-group">
443
+ <button className="action-button" onClick={handleShareConfig}>
444
+ Share Config
445
+ </button>
446
+ <button className="reset-button" onClick={handleResetToDefault}>
447
+ Reset
448
+ </button>
449
+ </div>
450
+
451
+ {/* === Idle mode indicator === */}
452
+ {idleMode && (
453
+ <p className="idle-indicator">Screensaver mode active</p>
454
+ )}
455
+ </section>
456
+ </div>
457
+
458
+ {/* === CUSTOM FEATURE: Keyboard shortcuts overlay === */}
459
+ {showShortcuts && (
460
+ <div className="shortcuts-overlay" onClick={() => setShowShortcuts(false)}>
461
+ <div className="shortcuts-panel" onClick={(e) => e.stopPropagation()}>
462
+ <h3>Keyboard Shortcuts</h3>
463
+ <p className="shortcuts-subtitle">Custom controls built on top of the SDK</p>
464
+ <ul>
465
+ <li><kbd>Space</kbd> Trigger formation</li>
466
+ <li><kbd>↑</kbd> / <kbd>↓</kbd> Adjust bird count</li>
467
+ <li><kbd>R</kbd> Toggle rainbow mode</li>
468
+ <li><kbd>S</kbd> Share config URL</li>
469
+ <li><kbd>A</kbd> Toggle auto-pilot</li>
470
+ <li><kbd>1-4</kbd> Apply presets</li>
471
+ <li><kbd>?</kbd> Show/hide this help</li>
472
+ </ul>
473
+ <button className="close-button" onClick={() => setShowShortcuts(false)}>
474
+ Close
475
+ </button>
476
+ </div>
477
+ </div>
478
+ )}
479
+ </div>
480
+ );
481
+ }
482
+
483
+ export default App;
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+
5
+ ReactDOM.createRoot(document.getElementById('root')!).render(
6
+ <React.StrictMode>
7
+ <App />
8
+ </React.StrictMode>
9
+ );
@@ -0,0 +1,24 @@
1
+ // Type definitions for the exported web component
2
+ export interface BoidsFlockingProjectElement extends HTMLElement {
3
+ setParam(key: string, value: unknown): void;
4
+ setParams(params: Record<string, unknown>): void;
5
+ getParams(): Record<string, unknown>;
6
+ getParamDefs(): Record<string, { type: string; value: unknown; [key: string]: unknown }>;
7
+ }
8
+
9
+ declare global {
10
+ namespace JSX {
11
+ interface IntrinsicElements {
12
+ 'boids-flocking-project': React.DetailedHTMLProps<
13
+ React.HTMLAttributes<BoidsFlockingProjectElement> & {
14
+ ref?: React.Ref<BoidsFlockingProjectElement>;
15
+ },
16
+ BoidsFlockingProjectElement
17
+ >;
18
+ }
19
+ }
20
+
21
+ interface HTMLElementTagNameMap {
22
+ 'boids-flocking-project': BoidsFlockingProjectElement;
23
+ }
24
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true
18
+ },
19
+ "include": ["src"]
20
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ },
9
+ });
@@ -0,0 +1,113 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HyperTool SDK - Recording Example</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: system-ui, sans-serif;
11
+ background: #1a1a2e;
12
+ color: #fff;
13
+ min-height: 100vh;
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ padding: 2rem;
18
+ }
19
+ h1 { margin-bottom: 0.5rem; }
20
+ .subtitle { color: #888; margin-bottom: 1.5rem; }
21
+ #canvas-container {
22
+ width: 800px;
23
+ height: 600px;
24
+ border-radius: 8px;
25
+ overflow: hidden;
26
+ background: #000;
27
+ }
28
+ .controls {
29
+ margin-top: 1.5rem;
30
+ display: flex;
31
+ gap: 1rem;
32
+ flex-wrap: wrap;
33
+ justify-content: center;
34
+ }
35
+ button {
36
+ padding: 0.75rem 1.5rem;
37
+ border: none;
38
+ border-radius: 4px;
39
+ background: #4a4a6a;
40
+ color: #fff;
41
+ cursor: pointer;
42
+ font-size: 1rem;
43
+ transition: background 0.2s;
44
+ }
45
+ button:hover { background: #5a5a7a; }
46
+ button:disabled {
47
+ opacity: 0.5;
48
+ cursor: not-allowed;
49
+ }
50
+ button.recording {
51
+ background: #ff4444;
52
+ animation: pulse 1s infinite;
53
+ }
54
+ button.primary { background: #00ff88; color: #000; }
55
+ @keyframes pulse {
56
+ 0%, 100% { opacity: 1; }
57
+ 50% { opacity: 0.7; }
58
+ }
59
+ .status {
60
+ margin-top: 1rem;
61
+ padding: 1rem;
62
+ background: #2a2a4a;
63
+ border-radius: 8px;
64
+ font-family: monospace;
65
+ font-size: 0.9rem;
66
+ min-width: 300px;
67
+ }
68
+ .timeline-section {
69
+ margin-top: 2rem;
70
+ padding: 1.5rem;
71
+ background: #2a2a4a;
72
+ border-radius: 8px;
73
+ width: 100%;
74
+ max-width: 800px;
75
+ }
76
+ .timeline-section h2 { margin-bottom: 1rem; font-size: 1.2rem; }
77
+ .timeline-controls { display: flex; gap: 1rem; flex-wrap: wrap; }
78
+ input[type="range"] { width: 200px; }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <h1>Recording & Timeline Example</h1>
83
+ <p class="subtitle">Video capture and keyframe animation</p>
84
+
85
+ <div id="canvas-container"></div>
86
+
87
+ <div class="controls">
88
+ <button id="capture-png">Capture PNG</button>
89
+ <button id="start-recording">Start Recording</button>
90
+ <button id="stop-recording" disabled>Stop Recording</button>
91
+ </div>
92
+
93
+ <div class="status">
94
+ <div id="status-text">Ready</div>
95
+ <div id="frame-count">Frame: 0</div>
96
+ </div>
97
+
98
+ <div class="timeline-section">
99
+ <h2>Timeline Animation</h2>
100
+ <div class="timeline-controls">
101
+ <button id="play-timeline" class="primary">Play Timeline</button>
102
+ <button id="pause-timeline">Pause</button>
103
+ <button id="reset-timeline">Reset</button>
104
+ <label>
105
+ Seek:
106
+ <input type="range" id="seek-timeline" min="0" max="5000" value="0">
107
+ </label>
108
+ </div>
109
+ </div>
110
+
111
+ <script type="module" src="./main.ts"></script>
112
+ </body>
113
+ </html>