ai-progress-controls 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +823 -0
  3. package/dist/ai-progress-controls.es.js +7191 -0
  4. package/dist/ai-progress-controls.es.js.map +1 -0
  5. package/dist/ai-progress-controls.umd.js +2 -0
  6. package/dist/ai-progress-controls.umd.js.map +1 -0
  7. package/dist/index.d.ts +2212 -0
  8. package/package.json +105 -0
  9. package/src/__tests__/setup.ts +93 -0
  10. package/src/core/base/AIControl.ts +230 -0
  11. package/src/core/base/index.ts +3 -0
  12. package/src/core/base/types.ts +77 -0
  13. package/src/core/base/utils.ts +168 -0
  14. package/src/core/batch-progress/BatchProgress.test.ts +458 -0
  15. package/src/core/batch-progress/BatchProgress.ts +760 -0
  16. package/src/core/batch-progress/index.ts +14 -0
  17. package/src/core/batch-progress/styles.ts +480 -0
  18. package/src/core/batch-progress/types.ts +169 -0
  19. package/src/core/model-loader/ModelLoader.test.ts +311 -0
  20. package/src/core/model-loader/ModelLoader.ts +673 -0
  21. package/src/core/model-loader/index.ts +2 -0
  22. package/src/core/model-loader/styles.ts +496 -0
  23. package/src/core/model-loader/types.ts +127 -0
  24. package/src/core/parameter-panel/ParameterPanel.test.ts +856 -0
  25. package/src/core/parameter-panel/ParameterPanel.ts +877 -0
  26. package/src/core/parameter-panel/index.ts +14 -0
  27. package/src/core/parameter-panel/styles.ts +323 -0
  28. package/src/core/parameter-panel/types.ts +278 -0
  29. package/src/core/parameter-slider/ParameterSlider.test.ts +299 -0
  30. package/src/core/parameter-slider/ParameterSlider.ts +653 -0
  31. package/src/core/parameter-slider/index.ts +8 -0
  32. package/src/core/parameter-slider/styles.ts +493 -0
  33. package/src/core/parameter-slider/types.ts +107 -0
  34. package/src/core/queue-progress/QueueProgress.test.ts +344 -0
  35. package/src/core/queue-progress/QueueProgress.ts +563 -0
  36. package/src/core/queue-progress/index.ts +5 -0
  37. package/src/core/queue-progress/styles.ts +469 -0
  38. package/src/core/queue-progress/types.ts +130 -0
  39. package/src/core/retry-progress/RetryProgress.test.ts +397 -0
  40. package/src/core/retry-progress/RetryProgress.ts +957 -0
  41. package/src/core/retry-progress/index.ts +6 -0
  42. package/src/core/retry-progress/styles.ts +530 -0
  43. package/src/core/retry-progress/types.ts +176 -0
  44. package/src/core/stream-progress/StreamProgress.test.ts +531 -0
  45. package/src/core/stream-progress/StreamProgress.ts +517 -0
  46. package/src/core/stream-progress/index.ts +2 -0
  47. package/src/core/stream-progress/styles.ts +349 -0
  48. package/src/core/stream-progress/types.ts +82 -0
  49. package/src/index.ts +19 -0
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "ai-progress-controls",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic UI controls for AI/ML workflows - streaming progress, model loading, LLM parameters, and more",
5
+ "keywords": [
6
+ "ai",
7
+ "progress",
8
+ "slider",
9
+ "llm",
10
+ "web-components",
11
+ "openai",
12
+ "anthropic",
13
+ "streaming",
14
+ "ui-components",
15
+ "typescript",
16
+ "framework-agnostic",
17
+ "accessibility",
18
+ "developer-friendly"
19
+ ],
20
+ "author": "Maneesh Thakur",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/Maneesh-Relanto/ai-progress-controls.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/Maneesh-Relanto/ai-progress-controls/issues"
28
+ },
29
+ "homepage": "https://github.com/Maneesh-Relanto/ai-progress-controls#readme",
30
+ "main": "dist/ai-progress-controls.umd.js",
31
+ "module": "dist/ai-progress-controls.es.js",
32
+ "types": "dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "dev": "vite",
41
+ "build": "tsc && vite build",
42
+ "preview": "vite preview",
43
+ "test": "vitest",
44
+ "test:ui": "vitest --ui",
45
+ "test:coverage": "vitest --coverage",
46
+ "lint": "eslint src --ext .ts,.tsx",
47
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
48
+ "format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
49
+ "format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"",
50
+ "type-check": "tsc --noEmit",
51
+ "validate": "npm run type-check && npm run lint && npm run format:check && npm run test:coverage",
52
+ "security:audit": "npm audit --audit-level=moderate",
53
+ "security:fix": "npm audit fix",
54
+ "bundle:analyze": "vite build --mode analyze",
55
+ "lighthouse": "npm run lighthouse:index && npm run lighthouse:examples",
56
+ "lighthouse:index": "lighthouse http://localhost:5174 --output html --output-path ./lighthouse-reports/index.html --view",
57
+ "lighthouse:examples": "lighthouse http://localhost:5174/examples/index.html --output html --output-path ./lighthouse-reports/examples.html --view",
58
+ "lighthouse:ci": "lhci autorun",
59
+ "prepare": "husky install",
60
+ "a11y:check": "axe http://localhost:5174 --exit",
61
+ "deps:check": "depcheck",
62
+ "deps:update": "ncu -u",
63
+ "docs:dev": "vitepress dev docs",
64
+ "docs:build": "vitepress build docs",
65
+ "docs:preview": "vitepress preview docs"
66
+ },
67
+ "lint-staged": {
68
+ "*.{ts,tsx}": [
69
+ "eslint --fix --ignore-pattern 'test-apps/**' --ignore-pattern 'examples/**' --ignore-pattern 'adapters/**'",
70
+ "prettier --write"
71
+ ],
72
+ "*.{json,md}": [
73
+ "prettier --write"
74
+ ]
75
+ },
76
+ "devDependencies": {
77
+ "@axe-core/cli": "^4.11.0",
78
+ "@lhci/cli": "^0.15.1",
79
+ "@types/node": "^20.19.28",
80
+ "@typescript-eslint/eslint-plugin": "^6.13.0",
81
+ "@typescript-eslint/parser": "^6.13.0",
82
+ "@vitest/coverage-v8": "^1.6.1",
83
+ "@vitest/ui": "^1.0.0",
84
+ "bundle-analyzer": "^0.0.6",
85
+ "depcheck": "^1.4.7",
86
+ "eslint": "^8.55.0",
87
+ "husky": "^9.1.7",
88
+ "jsdom": "^27.4.0",
89
+ "lighthouse": "^13.0.1",
90
+ "lint-staged": "^16.2.7",
91
+ "npm-check-updates": "^19.3.1",
92
+ "prettier": "^3.1.0",
93
+ "terser": "^5.44.1",
94
+ "typescript": "^5.3.0",
95
+ "vite": "^5.0.0",
96
+ "vite-plugin-dts": "^3.6.0",
97
+ "vitepress": "^1.0.0",
98
+ "vitest": "^1.0.0",
99
+ "wait-on": "^9.0.3"
100
+ },
101
+ "engines": {
102
+ "node": ">=18.0.0",
103
+ "npm": ">=9.0.0"
104
+ }
105
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Test setup file for Vitest
3
+ * Configures jsdom environment and utilities for Web Components testing
4
+ */
5
+
6
+ import { beforeAll, afterEach, vi } from 'vitest';
7
+
8
+ // Mock globalThis.customElements if not available
9
+ beforeAll(() => {
10
+ if (!globalThis.customElements) {
11
+ // @ts-expect-error - Mock for testing
12
+ globalThis.customElements = {
13
+ define: vi.fn(),
14
+ get: vi.fn(),
15
+ upgrade: vi.fn(),
16
+ whenDefined: vi.fn(() => Promise.resolve(class MockElement {} as CustomElementConstructor)),
17
+ };
18
+ }
19
+ });
20
+
21
+ // Clean up after each test
22
+ afterEach(() => {
23
+ document.body.innerHTML = '';
24
+ vi.clearAllMocks();
25
+ vi.clearAllTimers();
26
+ });
27
+
28
+ // Polyfill for Custom Elements if needed
29
+ if (!globalThis.customElements) {
30
+ class CustomElementRegistry {
31
+ private readonly definitions = new Map<string, CustomElementConstructor>();
32
+
33
+ define(name: string, constructor: CustomElementConstructor) {
34
+ this.definitions.set(name, constructor);
35
+ }
36
+
37
+ get(name: string) {
38
+ return this.definitions.get(name);
39
+ }
40
+
41
+ whenDefined(name: string): Promise<CustomElementConstructor> {
42
+ return Promise.resolve(this.definitions.get(name)!);
43
+ }
44
+
45
+ upgrade(_root: Node) {
46
+ // No-op for testing
47
+ }
48
+ }
49
+
50
+ // @ts-expect-error - Polyfill for testing
51
+ globalThis.customElements = new CustomElementRegistry();
52
+ }
53
+
54
+ // Mock requestAnimationFrame for testing
55
+ globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => {
56
+ return setTimeout(callback, 0) as unknown as number;
57
+ };
58
+
59
+ globalThis.cancelAnimationFrame = (id: number) => {
60
+ clearTimeout(id);
61
+ };
62
+
63
+ // Helper to wait for custom element to be defined
64
+ export async function waitForElement(element: HTMLElement): Promise<void> {
65
+ return new Promise((resolve) => {
66
+ if (element.shadowRoot) {
67
+ resolve();
68
+ } else {
69
+ setTimeout(resolve, 0);
70
+ }
71
+ });
72
+ }
73
+
74
+ // Helper to wait for async operations
75
+ export function waitForNextTick(): Promise<void> {
76
+ return new Promise((resolve) => setTimeout(resolve, 0));
77
+ }
78
+
79
+ // Helper to wait for multiple ticks
80
+ export function waitForTicks(count: number): Promise<void> {
81
+ return new Promise((resolve) => {
82
+ let ticks = 0;
83
+ const tick = () => {
84
+ ticks++;
85
+ if (ticks >= count) {
86
+ resolve();
87
+ } else {
88
+ setTimeout(tick, 0);
89
+ }
90
+ };
91
+ tick();
92
+ });
93
+ }
@@ -0,0 +1,230 @@
1
+ import type { AIControlConfig, ThemeConfig } from './types';
2
+
3
+ /**
4
+ * Base class for all AI Progress Controls
5
+ * Provides common functionality for Web Components including:
6
+ * - Theme management
7
+ * - Event handling
8
+ * - Accessibility features
9
+ * - Debug logging
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * class MyControl extends AIControl {
14
+ * constructor() {
15
+ * super();
16
+ * this.attachShadow({ mode: 'open' });
17
+ * }
18
+ *
19
+ * protected render() {
20
+ * // Your rendering logic
21
+ * }
22
+ * }
23
+ * ```
24
+ */
25
+ export abstract class AIControl extends HTMLElement {
26
+ protected config: AIControlConfig;
27
+ protected _disabled: boolean = false;
28
+ protected startTime: number = 0;
29
+
30
+ constructor(config: AIControlConfig = {}) {
31
+ super();
32
+ this.config = {
33
+ debug: false,
34
+ disabled: false,
35
+ ...config,
36
+ };
37
+ this._disabled = this.config.disabled ?? false;
38
+ }
39
+
40
+ /**
41
+ * Called when the element is connected to the DOM
42
+ */
43
+ connectedCallback(): void {
44
+ this.log('Component connected to DOM');
45
+ this.applyTheme();
46
+ this.setupAccessibility();
47
+ this.render();
48
+ }
49
+
50
+ /**
51
+ * Called when the element is disconnected from the DOM
52
+ */
53
+ disconnectedCallback(): void {
54
+ this.log('Component disconnected from DOM');
55
+ this.cleanup();
56
+ }
57
+
58
+ /**
59
+ * Called when an observed attribute changes
60
+ */
61
+ attributeChangedCallback(name: string, oldValue: string, newValue: string): void {
62
+ this.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
63
+ this.handleAttributeChange(name, oldValue, newValue);
64
+ }
65
+
66
+ /**
67
+ * Apply theme using CSS custom properties
68
+ */
69
+ protected applyTheme(theme?: ThemeConfig): void {
70
+ if (!theme) return;
71
+
72
+ const root = this.shadowRoot?.host as HTMLElement;
73
+ if (!root) return;
74
+
75
+ Object.entries(theme).forEach(([key, value]) => {
76
+ const cssVarName = `--ai-${key.replaceAll(/([A-Z])/g, '-$1').toLowerCase()}`;
77
+ root.style.setProperty(cssVarName, value);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Set up accessibility features (ARIA attributes, keyboard navigation)
83
+ */
84
+ protected setupAccessibility(): void {
85
+ // Set role if not already set
86
+ if (!this.getAttribute('role')) {
87
+ this.setAttribute('role', this.getDefaultRole());
88
+ }
89
+
90
+ // Set aria-label if configured
91
+ if (this.config.ariaLabel) {
92
+ this.setAttribute('aria-label', this.config.ariaLabel);
93
+ }
94
+
95
+ // Set disabled state
96
+ if (this._disabled) {
97
+ this.setAttribute('aria-disabled', 'true');
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Emit a custom event
103
+ */
104
+ protected emit<T>(eventName: string, detail?: T): void {
105
+ const event = new CustomEvent(eventName, {
106
+ detail,
107
+ bubbles: true,
108
+ composed: true,
109
+ });
110
+ this.dispatchEvent(event);
111
+ this.log(`Event emitted: ${eventName}`, detail);
112
+ }
113
+
114
+ /**
115
+ * Log debug messages (only if debug mode is enabled)
116
+ */
117
+ protected log(message: string, ...args: unknown[]): void {
118
+ if (this.config.debug) {
119
+ console.log(`[${this.constructor.name}] ${message}`, ...args);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Log errors (always logged)
125
+ */
126
+ protected logError(message: string, error?: Error): void {
127
+ console.error(`[${this.constructor.name}] ERROR: ${message}`, error);
128
+ }
129
+
130
+ /**
131
+ * Start timing an operation
132
+ */
133
+ protected startTimer(): void {
134
+ this.startTime = Date.now();
135
+ }
136
+
137
+ /**
138
+ * Get elapsed time since timer started
139
+ */
140
+ protected getElapsedTime(): number {
141
+ return Date.now() - this.startTime;
142
+ }
143
+
144
+ /**
145
+ * Format numbers for display (e.g., 1234 -> "1.2K")
146
+ */
147
+ protected formatNumber(num: number): string {
148
+ if (num >= 1_000_000) {
149
+ return `${(num / 1_000_000).toFixed(1)}M`;
150
+ }
151
+ if (num >= 1_000) {
152
+ return `${(num / 1_000).toFixed(1)}K`;
153
+ }
154
+ return num.toString();
155
+ }
156
+
157
+ /**
158
+ * Format duration in milliseconds to human-readable string
159
+ */
160
+ protected formatDuration(ms: number): string {
161
+ const seconds = Math.floor(ms / 1000);
162
+ const minutes = Math.floor(seconds / 60);
163
+ const hours = Math.floor(minutes / 60);
164
+
165
+ if (hours > 0) {
166
+ return `${hours}h ${minutes % 60}m`;
167
+ }
168
+ if (minutes > 0) {
169
+ return `${minutes}m ${seconds % 60}s`;
170
+ }
171
+ return `${seconds}s`;
172
+ }
173
+
174
+ /**
175
+ * Calculate percentage safely
176
+ */
177
+ protected calculatePercentage(current: number, total: number): number {
178
+ if (total === 0) return 0;
179
+ return Math.min(100, Math.max(0, (current / total) * 100));
180
+ }
181
+
182
+ /**
183
+ * Get/Set disabled state
184
+ */
185
+ get disabled(): boolean {
186
+ return this._disabled;
187
+ }
188
+
189
+ set disabled(value: boolean) {
190
+ this._disabled = value;
191
+ if (value) {
192
+ this.setAttribute('aria-disabled', 'true');
193
+ this.setAttribute('disabled', '');
194
+ } else {
195
+ this.setAttribute('aria-disabled', 'false');
196
+ this.removeAttribute('disabled');
197
+ }
198
+ this.render();
199
+ }
200
+
201
+ /**
202
+ * Abstract method to render the component
203
+ * Must be implemented by subclasses
204
+ */
205
+ protected abstract render(): void;
206
+
207
+ /**
208
+ * Get the default ARIA role for this component
209
+ * Can be overridden by subclasses
210
+ */
211
+ protected getDefaultRole(): string {
212
+ return 'region';
213
+ }
214
+
215
+ /**
216
+ * Handle attribute changes
217
+ * Can be overridden by subclasses to handle specific attributes
218
+ */
219
+ protected handleAttributeChange(_name: string, _oldValue: string, _newValue: string): void {
220
+ // Override in subclasses
221
+ }
222
+
223
+ /**
224
+ * Cleanup resources when component is destroyed
225
+ * Can be overridden by subclasses
226
+ */
227
+ protected cleanup(): void {
228
+ // Override in subclasses
229
+ }
230
+ }
@@ -0,0 +1,3 @@
1
+ export * from './AIControl';
2
+ export * from './types';
3
+ export * from './utils';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Size variant for components
3
+ */
4
+ export type SizeVariant = 'compact' | 'default' | 'large';
5
+
6
+ /**
7
+ * Visual style variant for components
8
+ */
9
+ export type VisualVariant = 'default' | 'minimal' | 'gradient' | 'glassmorphic';
10
+
11
+ /**
12
+ * Animation effect for progress indicators
13
+ */
14
+ export type AnimationEffect = 'none' | 'striped' | 'pulse' | 'glow';
15
+
16
+ /**
17
+ * Base configuration for all AI Progress Controls
18
+ */
19
+ export interface AIControlConfig {
20
+ /** Enable/disable console logging for debugging */
21
+ debug?: boolean;
22
+ /** Custom CSS class names to apply */
23
+ className?: string;
24
+ /** Whether the control is disabled */
25
+ disabled?: boolean;
26
+ /** ARIA label for accessibility */
27
+ ariaLabel?: string;
28
+ /** Enable automatic cursor feedback based on state (default: true) */
29
+ cursorFeedback?: boolean;
30
+ /** Size variant: compact, default, or large */
31
+ size?: SizeVariant;
32
+ /** Visual style variant: default, minimal, gradient, or glassmorphic */
33
+ variant?: VisualVariant;
34
+ /** Animation effect: none, striped, pulse, or glow */
35
+ animation?: AnimationEffect;
36
+ }
37
+
38
+ /**
39
+ * Theme configuration using CSS custom properties
40
+ */
41
+ export interface ThemeConfig {
42
+ primaryColor?: string;
43
+ secondaryColor?: string;
44
+ backgroundColor?: string;
45
+ textColor?: string;
46
+ borderRadius?: string;
47
+ fontSize?: string;
48
+ fontFamily?: string;
49
+ }
50
+
51
+ /**
52
+ * Event detail for progress updates
53
+ */
54
+ export interface ProgressUpdateEvent {
55
+ current: number;
56
+ total?: number;
57
+ percentage?: number;
58
+ message?: string;
59
+ }
60
+
61
+ /**
62
+ * Event detail for errors
63
+ */
64
+ export interface ErrorEvent {
65
+ message: string;
66
+ code?: string;
67
+ timestamp: number;
68
+ }
69
+
70
+ /**
71
+ * Event detail for completion
72
+ */
73
+ export interface CompleteEvent {
74
+ duration: number;
75
+ timestamp: number;
76
+ metadata?: Record<string, unknown>;
77
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Utility functions for AI Progress Controls
3
+ */
4
+
5
+ /**
6
+ * Debounce function calls
7
+ */
8
+ export function debounce<T extends (...args: any[]) => any>(
9
+ func: T,
10
+ wait: number
11
+ ): (...args: Parameters<T>) => void {
12
+ let timeout: ReturnType<typeof setTimeout> | null = null;
13
+
14
+ return function (this: any, ...args: Parameters<T>) {
15
+ if (timeout) clearTimeout(timeout);
16
+ timeout = setTimeout(() => func.apply(this, args), wait);
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Throttle function calls
22
+ */
23
+ export function throttle<T extends (...args: any[]) => any>(
24
+ func: T,
25
+ limit: number
26
+ ): (...args: Parameters<T>) => void {
27
+ let inThrottle: boolean;
28
+ return function (this: any, ...args: Parameters<T>) {
29
+ if (!inThrottle) {
30
+ func.apply(this, args);
31
+ inThrottle = true;
32
+ setTimeout(() => (inThrottle = false), limit);
33
+ }
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Clamp a number between min and max
39
+ */
40
+ export function clamp(value: number, min: number, max: number): number {
41
+ return Math.min(Math.max(value, min), max);
42
+ }
43
+
44
+ /**
45
+ * Linear interpolation
46
+ */
47
+ export function lerp(start: number, end: number, t: number): number {
48
+ return start + (end - start) * t;
49
+ }
50
+
51
+ /**
52
+ * Easing function for smooth animations
53
+ */
54
+ export function easeOutCubic(t: number): number {
55
+ return 1 - Math.pow(1 - t, 3);
56
+ }
57
+
58
+ /**
59
+ * Generate a unique ID
60
+ */
61
+ export function generateId(prefix: string = 'ai-control'): string {
62
+ return `${prefix}-${Math.random().toString(36).slice(2, 11)}`;
63
+ }
64
+
65
+ /**
66
+ * Check if reduced motion is preferred
67
+ */
68
+ export function prefersReducedMotion(): boolean {
69
+ return globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
70
+ }
71
+
72
+ /**
73
+ * Format bytes to human-readable string
74
+ */
75
+ export function formatBytes(bytes: number, decimals: number = 2): string {
76
+ if (bytes === 0) return '0 Bytes';
77
+
78
+ const k = 1024;
79
+ const dm = Math.max(0, decimals);
80
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
81
+
82
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
83
+
84
+ return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
85
+ }
86
+
87
+ /**
88
+ * Format currency (USD)
89
+ */
90
+ export function formatCurrency(amount: number, decimals: number = 4): string {
91
+ return `$${amount.toFixed(decimals)}`;
92
+ }
93
+
94
+ /**
95
+ * Format seconds to human-readable time string
96
+ */
97
+ export function formatTime(seconds: number): string {
98
+ if (seconds < 60) {
99
+ return `${Math.round(seconds)}s`;
100
+ }
101
+ const minutes = Math.floor(seconds / 60);
102
+ const remainingSeconds = Math.round(seconds % 60);
103
+ if (minutes < 60) {
104
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
105
+ }
106
+ const hours = Math.floor(minutes / 60);
107
+ const remainingMinutes = minutes % 60;
108
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
109
+ }
110
+
111
+ /**
112
+ * Calculate estimated time remaining
113
+ */
114
+ export function calculateETA(current: number, total: number, startTime: number): number {
115
+ if (current === 0) return 0;
116
+
117
+ const elapsed = Date.now() - startTime;
118
+ const rate = current / elapsed;
119
+ const remaining = total - current;
120
+
121
+ return remaining / rate;
122
+ }
123
+
124
+ /**
125
+ * Parse CSS color to RGB
126
+ */
127
+ export function parseColor(color: string): { r: number; g: number; b: number } | null {
128
+ const div = document.createElement('div');
129
+ div.style.color = color;
130
+ document.body.appendChild(div);
131
+
132
+ const computedColor = getComputedStyle(div).color;
133
+ div.remove();
134
+
135
+ const regex = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/;
136
+ const match = regex.exec(computedColor);
137
+ if (match?.[1] && match?.[2] && match?.[3]) {
138
+ return {
139
+ r: Number.parseInt(match[1], 10),
140
+ g: Number.parseInt(match[2], 10),
141
+ b: Number.parseInt(match[3], 10),
142
+ };
143
+ }
144
+
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Create a style element with CSS
150
+ */
151
+ export function createStyle(css: string): HTMLStyleElement {
152
+ const style = document.createElement('style');
153
+ style.textContent = css;
154
+ return style;
155
+ }
156
+
157
+ /**
158
+ * Check if an element is in viewport
159
+ */
160
+ export function isInViewport(element: HTMLElement): boolean {
161
+ const rect = element.getBoundingClientRect();
162
+ return (
163
+ rect.top >= 0 &&
164
+ rect.left >= 0 &&
165
+ rect.bottom <= (globalThis.innerHeight || document.documentElement.clientHeight) &&
166
+ rect.right <= (globalThis.innerWidth || document.documentElement.clientWidth)
167
+ );
168
+ }