@happyvertical/smrt-images 0.30.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 (75) hide show
  1. package/AGENTS.md +48 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +92 -0
  5. package/dist/__smrt-register__.d.ts +2 -0
  6. package/dist/__smrt-register__.d.ts.map +1 -0
  7. package/dist/categorizer.d.ts +26 -0
  8. package/dist/categorizer.d.ts.map +1 -0
  9. package/dist/deriver.d.ts +33 -0
  10. package/dist/deriver.d.ts.map +1 -0
  11. package/dist/editor.d.ts +72 -0
  12. package/dist/editor.d.ts.map +1 -0
  13. package/dist/image.d.ts +53 -0
  14. package/dist/image.d.ts.map +1 -0
  15. package/dist/images.d.ts +80 -0
  16. package/dist/images.d.ts.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +839 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/manifest.json +1179 -0
  22. package/dist/media-bundle-persistence.d.ts +15 -0
  23. package/dist/media-bundle-persistence.d.ts.map +1 -0
  24. package/dist/metadata.d.ts +19 -0
  25. package/dist/metadata.d.ts.map +1 -0
  26. package/dist/playground.d.ts +2 -0
  27. package/dist/playground.d.ts.map +1 -0
  28. package/dist/playground.js +140 -0
  29. package/dist/playground.js.map +1 -0
  30. package/dist/prompts.d.ts +8 -0
  31. package/dist/prompts.d.ts.map +1 -0
  32. package/dist/search.d.ts +42 -0
  33. package/dist/search.d.ts.map +1 -0
  34. package/dist/smrt-knowledge.json +561 -0
  35. package/dist/svelte/components/AssetsGallery.svelte +436 -0
  36. package/dist/svelte/components/AssetsGallery.svelte.d.ts +11 -0
  37. package/dist/svelte/components/AssetsGallery.svelte.d.ts.map +1 -0
  38. package/dist/svelte/components/ImageEditor.svelte +485 -0
  39. package/dist/svelte/components/ImageEditor.svelte.d.ts +12 -0
  40. package/dist/svelte/components/ImageEditor.svelte.d.ts.map +1 -0
  41. package/dist/svelte/components/ImageUploader.svelte +922 -0
  42. package/dist/svelte/components/ImageUploader.svelte.d.ts +15 -0
  43. package/dist/svelte/components/ImageUploader.svelte.d.ts.map +1 -0
  44. package/dist/svelte/i18n.d.ts +42 -0
  45. package/dist/svelte/i18n.d.ts.map +1 -0
  46. package/dist/svelte/i18n.js +46 -0
  47. package/dist/svelte/image-clients.d.ts +45 -0
  48. package/dist/svelte/image-clients.d.ts.map +1 -0
  49. package/dist/svelte/image-clients.js +1 -0
  50. package/dist/svelte/index.d.ts +14 -0
  51. package/dist/svelte/index.d.ts.map +1 -0
  52. package/dist/svelte/index.js +21 -0
  53. package/dist/svelte/playground.d.ts +74 -0
  54. package/dist/svelte/playground.d.ts.map +1 -0
  55. package/dist/svelte/playground.js +105 -0
  56. package/dist/svelte/routes/ImageStudioRoute.svelte +194 -0
  57. package/dist/svelte/routes/ImageStudioRoute.svelte.d.ts +7 -0
  58. package/dist/svelte/routes/ImageStudioRoute.svelte.d.ts.map +1 -0
  59. package/dist/svelte/routes/index.d.ts +2 -0
  60. package/dist/svelte/routes/index.d.ts.map +1 -0
  61. package/dist/svelte/routes/index.js +1 -0
  62. package/dist/svelte/routes/shared.d.ts +25 -0
  63. package/dist/svelte/routes/shared.d.ts.map +1 -0
  64. package/dist/svelte/routes/shared.js +31 -0
  65. package/dist/types.d.ts +51 -0
  66. package/dist/types.d.ts.map +1 -0
  67. package/dist/types.js +2 -0
  68. package/dist/types.js.map +1 -0
  69. package/dist/ui.d.ts +10 -0
  70. package/dist/ui.d.ts.map +1 -0
  71. package/dist/ui.js +42 -0
  72. package/dist/ui.js.map +1 -0
  73. package/dist/upstream.d.ts +65 -0
  74. package/dist/upstream.d.ts.map +1 -0
  75. package/package.json +95 -0
@@ -0,0 +1,922 @@
1
+ <script lang="ts">
2
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
3
+ import { onDestroy } from 'svelte';
4
+ import { M } from '../i18n.js';
5
+ import type {
6
+ ImageEditorClient,
7
+ ImageLike,
8
+ ImagesGalleryClient,
9
+ } from '../image-clients';
10
+ import AssetsGallery from './AssetsGallery.svelte';
11
+
12
+ const { t } = useI18n();
13
+
14
+ let {
15
+ apiBaseUrl = '/api/v1',
16
+ editorClient = undefined,
17
+ galleryClient = undefined,
18
+ onSelect,
19
+ onCancel = undefined,
20
+ allowedTabs = ['gallery', 'upload', 'camera', 'external'],
21
+ enableDragToEditor = false,
22
+ }: {
23
+ apiBaseUrl?: string;
24
+ editorClient?: ImageEditorClient;
25
+ galleryClient?: ImagesGalleryClient;
26
+ /** @required Callback when an image is selected */
27
+ onSelect: (image: ImageLike | File | string) => void;
28
+ onCancel?: () => void;
29
+ allowedTabs?: ('gallery' | 'upload' | 'camera' | 'external')[];
30
+ enableDragToEditor?: boolean;
31
+ } = $props();
32
+
33
+ let activeTab = $state<string>();
34
+
35
+ // Ensure active tab defaults to the first allowed tab dynamically to fix the state-reference-locally lint
36
+ $effect(() => {
37
+ if (!activeTab && allowedTabs.length > 0) {
38
+ activeTab = allowedTabs[0];
39
+ }
40
+ });
41
+
42
+ $effect(() => {
43
+ if (!onSelect) {
44
+ throw new Error('ImageUploader: `onSelect` prop is required.');
45
+ }
46
+ });
47
+
48
+ // Upload state
49
+ let uploadInput: HTMLInputElement | undefined = $state();
50
+ let isDragging = $state(false);
51
+ let uploadError: string | null = $state(null);
52
+
53
+ // Camera state
54
+ let videoElement: HTMLVideoElement | undefined = $state();
55
+ let canvasElement: HTMLCanvasElement | undefined = $state();
56
+ let stream: MediaStream | null = $state(null);
57
+ let cameraError: string | null = $state(null);
58
+ let isCameraActive = $state(false);
59
+
60
+ // External state
61
+ let externalUrl = $state('');
62
+
63
+ // Tab switching cleanup
64
+ $effect(() => {
65
+ if (activeTab !== 'camera' && stream) {
66
+ stopCamera();
67
+ } else if (activeTab === 'camera' && !stream) {
68
+ startCamera();
69
+ }
70
+ });
71
+
72
+ // --- Upload Handlers ---
73
+
74
+ function handleDragOver(e: DragEvent) {
75
+ e.preventDefault();
76
+ isDragging = true;
77
+ }
78
+
79
+ function handleDragLeave() {
80
+ isDragging = false;
81
+ }
82
+
83
+ function handleDrop(e: DragEvent) {
84
+ e.preventDefault();
85
+ isDragging = false;
86
+ uploadError = null;
87
+
88
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
89
+ const file = e.dataTransfer.files[0];
90
+ if (!file.type.startsWith('image/')) {
91
+ uploadError = 'Please drop an image file.';
92
+ return;
93
+ }
94
+ onSelect(file);
95
+ }
96
+ }
97
+
98
+ function handleFileSelect(e: Event) {
99
+ uploadError = null;
100
+ const target = e.target as HTMLInputElement;
101
+ if (target.files && target.files.length > 0) {
102
+ const file = target.files[0];
103
+ if (!file.type.startsWith('image/')) {
104
+ uploadError = 'Please select an image file.';
105
+ return;
106
+ }
107
+ onSelect(file);
108
+ }
109
+ }
110
+
111
+ // --- Camera Handlers ---
112
+
113
+ async function startCamera() {
114
+ cameraError = null;
115
+ isCameraActive = false;
116
+ try {
117
+ stream = await navigator.mediaDevices.getUserMedia({
118
+ video: { facingMode: 'environment' },
119
+ audio: false,
120
+ });
121
+ if (videoElement) {
122
+ videoElement.srcObject = stream;
123
+ videoElement.play();
124
+ isCameraActive = true;
125
+ }
126
+ } catch (err: any) {
127
+ cameraError = `Could not access camera: ${err.message}`;
128
+ }
129
+ }
130
+
131
+ function stopCamera() {
132
+ if (stream) {
133
+ for (const track of stream.getTracks()) {
134
+ track.stop();
135
+ }
136
+ stream = null;
137
+ isCameraActive = false;
138
+ }
139
+ }
140
+
141
+ function takePicture() {
142
+ if (!videoElement || !canvasElement || !isCameraActive) return;
143
+
144
+ const context = canvasElement.getContext('2d');
145
+ if (!context) return;
146
+
147
+ // Set canvas dimensions to match video
148
+ canvasElement.width = videoElement.videoWidth;
149
+ canvasElement.height = videoElement.videoHeight;
150
+
151
+ // Draw current frame to canvas
152
+ context.drawImage(
153
+ videoElement,
154
+ 0,
155
+ 0,
156
+ canvasElement.width,
157
+ canvasElement.height,
158
+ );
159
+
160
+ // Convert to blob and return
161
+ canvasElement.toBlob(
162
+ (blob) => {
163
+ if (blob) {
164
+ const file = new File([blob], `camera-${Date.now()}.jpg`, {
165
+ type: 'image/jpeg',
166
+ });
167
+ onSelect(file);
168
+ stopCamera();
169
+ }
170
+ },
171
+ 'image/jpeg',
172
+ 0.9,
173
+ );
174
+ }
175
+
176
+ // --- External Handlers ---
177
+
178
+ function handleExternalSubmit() {
179
+ if (!externalUrl) return;
180
+ onSelect(externalUrl);
181
+ }
182
+
183
+ // --- Gallery Confirmation + Variation ---
184
+
185
+ let selectedImage: ImageLike | null = $state(null);
186
+ let showVariation = $state(false);
187
+ let variationPrompt = $state('');
188
+ let isGenerating = $state(false);
189
+ let variationError: string | null = $state(null);
190
+
191
+ function handleGalleryPick(image: ImageLike) {
192
+ selectedImage = image;
193
+ showVariation = false;
194
+ variationPrompt = '';
195
+ variationError = null;
196
+ }
197
+
198
+ function handleConfirmOriginal() {
199
+ if (!selectedImage) return;
200
+ onSelect(selectedImage);
201
+ selectedImage = null;
202
+ }
203
+
204
+ function handleBackToChooser() {
205
+ selectedImage = null;
206
+ showVariation = false;
207
+ variationPrompt = '';
208
+ variationError = null;
209
+ }
210
+
211
+ async function handleGenerateVariation() {
212
+ if (!selectedImage || !variationPrompt.trim()) return;
213
+ isGenerating = true;
214
+ variationError = null;
215
+
216
+ try {
217
+ const data = editorClient
218
+ ? await editorClient.edit(selectedImage.id, {
219
+ prompt: variationPrompt,
220
+ })
221
+ : await (async () => {
222
+ const res = await fetch(
223
+ `${apiBaseUrl}/images/${selectedImage.id}/edit`,
224
+ {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({ prompt: variationPrompt }),
228
+ },
229
+ );
230
+
231
+ if (!res.ok) {
232
+ let errText = await res.text();
233
+ if (errText.trim().startsWith('<'))
234
+ errText = `Server returned ${res.status} ${res.statusText}`;
235
+ throw new Error(errText);
236
+ }
237
+
238
+ return (await res.json()) as { image: ImageLike };
239
+ })();
240
+ onSelect(data.image);
241
+ selectedImage = null;
242
+ } catch (e: any) {
243
+ variationError = e.message || 'Failed to generate variation';
244
+ } finally {
245
+ isGenerating = false;
246
+ }
247
+ }
248
+
249
+ onDestroy(() => {
250
+ stopCamera();
251
+ });
252
+ </script>
253
+
254
+ <div class="smrt-image-uploader">
255
+ {#if selectedImage}
256
+ <!-- Gallery Confirmation Step -->
257
+ <div class="header">
258
+ <button type="button" class="back-btn" onclick={handleBackToChooser}>
259
+ <svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
260
+ <polyline points="15 18 9 12 15 6"></polyline>
261
+ </svg>
262
+ Back
263
+ </button>
264
+ {#if onCancel}
265
+ <button type="button" class="close-btn" onclick={onCancel}>×</button>
266
+ {/if}
267
+ </div>
268
+
269
+ <div class="confirm-panel">
270
+ <div class="confirm-preview-wrapper">
271
+ <img class="confirm-preview-img" src={selectedImage.sourceUri || selectedImage.url} alt={selectedImage.name} />
272
+ </div>
273
+
274
+ <div class="confirm-info">
275
+ <span class="confirm-name">{selectedImage.name}</span>
276
+ <span class="confirm-meta">{selectedImage.width}×{selectedImage.height} · {selectedImage.mimeType}</span>
277
+ </div>
278
+
279
+ <div class="confirm-actions">
280
+ <button type="button" class="primary-btn" onclick={handleConfirmOriginal}>
281
+ {t(M['images.image_uploader.select_image'])}
282
+ </button>
283
+ <button
284
+ type="button"
285
+ class="variation-toggle"
286
+ class:active={showVariation}
287
+ onclick={() => showVariation = !showVariation}
288
+ >
289
+ <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
290
+ <path d="M12 20h9"></path>
291
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
292
+ </svg>
293
+ {t(M['images.image_uploader.create_variation'])}
294
+ </button>
295
+ </div>
296
+
297
+ {#if showVariation}
298
+ <div class="variation-form">
299
+ <p class="variation-hint">{t(M['images.image_uploader.variation_hint'])}</p>
300
+ <textarea
301
+ bind:value={variationPrompt}
302
+ placeholder={t(M['images.image_uploader.variation_prompt_placeholder'])}
303
+ rows="3"
304
+ disabled={isGenerating}
305
+ ></textarea>
306
+ {#if variationError}
307
+ <div class="variation-error">{variationError}</div>
308
+ {/if}
309
+ <button
310
+ type="button"
311
+ class="generate-btn"
312
+ disabled={isGenerating || !variationPrompt.trim()}
313
+ onclick={handleGenerateVariation}
314
+ >
315
+ {#if isGenerating}
316
+ <span class="spinner"></span>
317
+ {t(M['images.image_uploader.generating'])}
318
+ {:else}
319
+ {t(M['images.image_uploader.generate_variation'])}
320
+ {/if}
321
+ </button>
322
+ </div>
323
+ {/if}
324
+ </div>
325
+
326
+ {:else}
327
+ <!-- Normal Chooser -->
328
+ <div class="header">
329
+ <h3>{t(M['images.image_uploader.choose_image'])}</h3>
330
+ {#if onCancel}
331
+ <button type="button" class="close-btn" onclick={onCancel}>×</button>
332
+ {/if}
333
+ </div>
334
+
335
+ <div class="tabs">
336
+ {#if allowedTabs.includes('gallery')}
337
+ <button type="button" class:active={activeTab === 'gallery'} onclick={() => activeTab = 'gallery'}>Gallery</button>
338
+ {/if}
339
+ {#if allowedTabs.includes('upload')}
340
+ <button type="button" class:active={activeTab === 'upload'} onclick={() => activeTab = 'upload'}>Upload</button>
341
+ {/if}
342
+ {#if allowedTabs.includes('camera')}
343
+ <button type="button" class:active={activeTab === 'camera'} onclick={() => activeTab = 'camera'}>Camera</button>
344
+ {/if}
345
+ {#if allowedTabs.includes('external')}
346
+ <button type="button" class:active={activeTab === 'external'} onclick={() => activeTab = 'external'}>{t(M['images.image_uploader.external_url'])}</button>
347
+ {/if}
348
+ </div>
349
+
350
+ <div class="tab-content">
351
+
352
+ {#if activeTab === 'gallery'}
353
+ <div class="gallery-wrapper">
354
+ <AssetsGallery
355
+ {apiBaseUrl}
356
+ client={galleryClient}
357
+ {enableDragToEditor}
358
+ onSelect={handleGalleryPick}
359
+ />
360
+ </div>
361
+
362
+ {:else if activeTab === 'upload'}
363
+ <div
364
+ class="upload-area"
365
+ class:dragging={isDragging}
366
+ ondragover={handleDragOver}
367
+ ondragleave={handleDragLeave}
368
+ ondrop={handleDrop}
369
+ onclick={() => uploadInput?.click()}
370
+ onkeydown={(e) => e.key === 'Enter' && uploadInput?.click()}
371
+ tabindex="0"
372
+ role="button"
373
+ >
374
+ <div class="upload-icon">
375
+ <svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
376
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
377
+ <polyline points="17 8 12 3 7 8"></polyline>
378
+ <line x1="12" y1="3" x2="12" y2="15"></line>
379
+ </svg>
380
+ </div>
381
+ <p>{t(M['images.image_uploader.drag_and_drop'])}</p>
382
+ <span class="divider">or</span>
383
+ <button type="button" class="browse-btn">{t(M['images.image_uploader.browse_files'])}</button>
384
+ <input
385
+ type="file"
386
+ accept="image/*"
387
+ bind:this={uploadInput}
388
+ onchange={handleFileSelect}
389
+ style="display: none;"
390
+ />
391
+ {#if uploadError}
392
+ <p class="error">{uploadError}</p>
393
+ {/if}
394
+ </div>
395
+
396
+ {:else if activeTab === 'camera'}
397
+ <div class="camera-area">
398
+ {#if cameraError}
399
+ <div class="error-panel">
400
+ <p>{cameraError}</p>
401
+ <button type="button" onclick={startCamera}>{t(M['images.image_uploader.try_again'])}</button>
402
+ </div>
403
+ {:else}
404
+ <div class="video-container">
405
+ <!-- svelte-ignore a11y_media_has_caption -->
406
+ <video bind:this={videoElement} autoplay playsinline></video>
407
+ {#if !isCameraActive}
408
+ <div class="loading-overlay">{t(M['images.image_uploader.starting_camera'])}</div>
409
+ {/if}
410
+ </div>
411
+ <button type="button" class="capture-btn" disabled={!isCameraActive} onclick={takePicture}>
412
+ {t(M['images.image_uploader.take_picture'])}
413
+ </button>
414
+ <canvas bind:this={canvasElement} style="display: none;"></canvas>
415
+ {/if}
416
+ </div>
417
+
418
+ {:else if activeTab === 'external'}
419
+ <div class="external-area">
420
+ <p class="hint">{t(M['images.image_uploader.external_hint'])}</p>
421
+ <div class="input-group">
422
+ <input
423
+ type="url"
424
+ bind:value={externalUrl}
425
+ placeholder={t(M['images.image_uploader.external_url_placeholder'])}
426
+ onkeydown={(e) => e.key === 'Enter' && handleExternalSubmit()}
427
+ />
428
+ <button
429
+ type="button"
430
+ class="submit-btn"
431
+ disabled={!externalUrl.trim()}
432
+ onclick={handleExternalSubmit}
433
+ >
434
+ Add
435
+ </button>
436
+ </div>
437
+ </div>
438
+ {/if}
439
+
440
+ </div>
441
+ {/if}
442
+ </div>
443
+
444
+ <style>
445
+ .smrt-image-uploader {
446
+ display: flex;
447
+ flex-direction: column;
448
+ background: var(--smrt-color-surface-container, #1a1a1a);
449
+ color: var(--smrt-color-on-surface, #fff);
450
+ border-radius: var(--smrt-radius-lg, 8px);
451
+ border: 1px solid var(--smrt-color-outline-variant, #333);
452
+ overflow: hidden;
453
+ height: 100%;
454
+ min-height: 500px;
455
+ }
456
+
457
+ .header {
458
+ display: flex;
459
+ justify-content: space-between;
460
+ align-items: center;
461
+ padding: 1rem 1.5rem;
462
+ border-bottom: 1px solid var(--smrt-color-outline-variant, #333);
463
+ }
464
+
465
+ .header h3 {
466
+ margin: 0;
467
+ font-size: var(--smrt-typography-title-medium-size, 1.15rem);
468
+ }
469
+
470
+ .close-btn {
471
+ background: transparent;
472
+ border: none;
473
+ color: var(--smrt-color-outline, #888);
474
+ font-size: var(--smrt-typography-headline-small-size, 1.5rem);
475
+ line-height: 1;
476
+ cursor: pointer;
477
+ }
478
+
479
+ .close-btn:hover {
480
+ color: var(--smrt-color-on-surface, #fff);
481
+ }
482
+
483
+ .tabs {
484
+ display: flex;
485
+ background: var(--smrt-color-surface-container-high, #242424);
486
+ border-bottom: 1px solid var(--smrt-color-outline-variant, #333);
487
+ }
488
+
489
+ .tabs button {
490
+ flex: 1;
491
+ background: transparent;
492
+ border: none;
493
+ border-bottom: 2px solid var(--smrt-color-outline-variant, #333);
494
+ padding: 1rem;
495
+ color: var(--smrt-color-outline, #888);
496
+ font-weight: var(--smrt-typography-weight-medium, 500);
497
+ cursor: pointer;
498
+ transition: all 0.2s;
499
+ text-transform: uppercase;
500
+ font-size: var(--smrt-typography-label-large-size, 0.85rem);
501
+ letter-spacing: var(--smrt-typography-label-large-tracking, 0.5px);
502
+ }
503
+
504
+ .tabs button:hover {
505
+ color: var(--smrt-color-on-surface-variant, #ccc);
506
+ background: color-mix(in srgb, var(--smrt-color-on-surface, #fff) 2%, transparent);
507
+ }
508
+
509
+ .tabs button.active {
510
+ color: var(--smrt-color-primary, #3b82f6);
511
+ border-bottom: 2px solid var(--smrt-color-primary, #3b82f6);
512
+ background: transparent;
513
+ }
514
+
515
+ .tab-content {
516
+ flex: 1;
517
+ display: flex;
518
+ flex-direction: column;
519
+ position: relative;
520
+ overflow: hidden;
521
+ }
522
+
523
+ /* Gallery Tab */
524
+ .gallery-wrapper {
525
+ flex: 1;
526
+ display: flex;
527
+ flex-direction: column;
528
+ overflow: hidden;
529
+ /* Gallery internal scroll handles the rest */
530
+ }
531
+
532
+ /* Upload Tab */
533
+ .upload-area {
534
+ flex: 1;
535
+ display: flex;
536
+ flex-direction: column;
537
+ align-items: center;
538
+ justify-content: center;
539
+ padding: 3rem;
540
+ margin: 1.5rem;
541
+ border: 2px dashed var(--smrt-color-outline-variant, #444);
542
+ border-radius: var(--smrt-radius-lg, 8px);
543
+ background: var(--smrt-color-surface-container-high, #242424);
544
+ cursor: pointer;
545
+ transition: all 0.2s;
546
+ }
547
+
548
+ .upload-area:hover, .upload-area:focus {
549
+ border-color: var(--smrt-color-outline, #666);
550
+ background: var(--smrt-color-surface-container-highest, #2a2a2a);
551
+ outline: none;
552
+ }
553
+
554
+ .upload-area.dragging {
555
+ border-color: var(--smrt-color-primary, #3b82f6);
556
+ background: color-mix(in srgb, var(--smrt-color-primary) 5%, transparent);
557
+ transform: scale(1.02);
558
+ }
559
+
560
+ .upload-icon {
561
+ color: var(--smrt-color-primary, #3b82f6);
562
+ margin-bottom: 1rem;
563
+ }
564
+
565
+ .upload-area p {
566
+ font-size: var(--smrt-typography-body-large-size, 1.1rem);
567
+ margin: 0 0 0.5rem 0;
568
+ }
569
+
570
+ .divider {
571
+ font-size: var(--smrt-typography-label-large-size, 0.85rem);
572
+ color: var(--smrt-color-outline, #666);
573
+ margin-bottom: 1rem;
574
+ }
575
+
576
+ .browse-btn {
577
+ background: var(--smrt-color-primary, #3b82f6);
578
+ color: white;
579
+ border: none;
580
+ padding: 0.5rem 1.5rem;
581
+ border-radius: var(--smrt-radius-full, 9999px);
582
+ font-weight: var(--smrt-typography-weight-medium, 500);
583
+ pointer-events: none; /* Let parent handle clicks */
584
+ }
585
+
586
+ .error {
587
+ margin-top: 1rem;
588
+ color: var(--smrt-color-error, #ef4444);
589
+ font-size: var(--smrt-typography-body-medium-size, 0.9rem);
590
+ }
591
+
592
+ /* Camera Tab */
593
+ .camera-area {
594
+ flex: 1;
595
+ display: flex;
596
+ flex-direction: column;
597
+ padding: 1.5rem;
598
+ gap: 1.5rem;
599
+ align-items: center;
600
+ justify-content: center;
601
+ background: var(--smrt-color-surface-dim, #000);
602
+ }
603
+
604
+ .video-container {
605
+ position: relative;
606
+ width: 100%;
607
+ max-width: 600px;
608
+ aspect-ratio: 4/3;
609
+ background: var(--smrt-color-surface-container-lowest, #111);
610
+ border-radius: var(--smrt-radius-md, 6px);
611
+ overflow: hidden;
612
+ }
613
+
614
+ video {
615
+ width: 100%;
616
+ height: 100%;
617
+ object-fit: contain;
618
+ }
619
+
620
+ .loading-overlay {
621
+ position: absolute;
622
+ top: 0; left: 0; right: 0; bottom: 0;
623
+ display: flex;
624
+ align-items: center;
625
+ justify-content: center;
626
+ background: var(--smrt-color-scrim, rgba(0, 0, 0, 0.7));
627
+ color: white;
628
+ }
629
+
630
+ .capture-btn {
631
+ background: var(--smrt-color-primary, #3b82f6);
632
+ color: white;
633
+ border: none;
634
+ padding: 1rem 3rem;
635
+ border-radius: var(--smrt-radius-full, 9999px);
636
+ font-size: var(--smrt-typography-body-large-size, 1.1rem);
637
+ font-weight: var(--smrt-typography-weight-semibold, 600);
638
+ cursor: pointer;
639
+ }
640
+
641
+ .capture-btn:disabled {
642
+ background: var(--smrt-color-outline-variant, #444);
643
+ color: var(--smrt-color-outline, #888);
644
+ cursor: not-allowed;
645
+ }
646
+
647
+ .error-panel {
648
+ text-align: center;
649
+ padding: 2rem;
650
+ background: var(--smrt-color-surface-container, #1a1a1a);
651
+ border-radius: var(--smrt-radius-md, 6px);
652
+ }
653
+
654
+ .error-panel p {
655
+ color: var(--smrt-color-error, #ef4444);
656
+ margin-bottom: 1rem;
657
+ }
658
+
659
+ .error-panel button {
660
+ background: var(--smrt-color-surface-container-high, #242424);
661
+ color: white;
662
+ border: 1px solid var(--smrt-color-outline-variant, #444);
663
+ padding: 0.5rem 1rem;
664
+ border-radius: var(--smrt-radius-sm, 4px);
665
+ cursor: pointer;
666
+ }
667
+
668
+ /* External Tab */
669
+ .external-area {
670
+ flex: 1;
671
+ display: flex;
672
+ flex-direction: column;
673
+ padding: 3rem 2rem;
674
+ align-items: center;
675
+ }
676
+
677
+ .hint {
678
+ color: var(--smrt-color-outline, #888);
679
+ margin-bottom: 2rem;
680
+ text-align: center;
681
+ }
682
+
683
+ .input-group {
684
+ display: flex;
685
+ width: 100%;
686
+ max-width: 500px;
687
+ gap: 0.5rem;
688
+ }
689
+
690
+ .input-group input {
691
+ flex: 1;
692
+ padding: 1rem 1.25rem;
693
+ background: var(--smrt-color-surface-container-high, #242424);
694
+ border: 1px solid var(--smrt-color-outline-variant, #444);
695
+ border-radius: var(--smrt-radius-sm, 4px);
696
+ color: var(--smrt-color-on-surface, #fff);
697
+ font-size: var(--smrt-typography-body-large-size, 1rem);
698
+ transition: border-color 0.2s, box-shadow 0.2s;
699
+ }
700
+
701
+ .input-group input:focus {
702
+ outline: none;
703
+ border-color: var(--smrt-color-primary, #3b82f6);
704
+ box-shadow: inset 0 0 0 1px var(--smrt-color-primary, #3b82f6);
705
+ }
706
+
707
+ .submit-btn {
708
+ background: var(--smrt-color-primary, #3b82f6);
709
+ color: white;
710
+ border: none;
711
+ padding: 0 1.5rem;
712
+ border-radius: var(--smrt-radius-md, 6px);
713
+ font-weight: var(--smrt-typography-weight-medium, 500);
714
+ cursor: pointer;
715
+ }
716
+
717
+ .submit-btn:disabled {
718
+ background: var(--smrt-color-surface-container-highest, #333);
719
+ color: var(--smrt-color-outline, #666);
720
+ cursor: not-allowed;
721
+ }
722
+
723
+ /* --- Back Button --- */
724
+ .back-btn {
725
+ display: flex;
726
+ align-items: center;
727
+ gap: 0.35rem;
728
+ background: transparent;
729
+ border: none;
730
+ color: var(--smrt-color-primary, #3b82f6);
731
+ font-weight: var(--smrt-typography-weight-medium, 500);
732
+ cursor: pointer;
733
+ padding: 0.25rem 0.5rem;
734
+ border-radius: var(--smrt-radius-sm, 4px);
735
+ transition: background 0.15s;
736
+ }
737
+
738
+ .back-btn:hover {
739
+ background: color-mix(in srgb, var(--smrt-color-primary) 8%, transparent);
740
+ }
741
+
742
+ .confirm-panel {
743
+ flex: 1;
744
+ display: flex;
745
+ flex-direction: column;
746
+ overflow: hidden; /* Prevent body scroll, let preview wrapper handle it */
747
+ }
748
+
749
+ .confirm-preview-wrapper {
750
+ flex: 1;
751
+ min-height: 0;
752
+ padding: 1.5rem 1.5rem 0;
753
+ display: flex;
754
+ justify-content: center;
755
+ align-items: center;
756
+ }
757
+
758
+ .confirm-preview-img {
759
+ width: 100%;
760
+ height: 100%;
761
+ min-height: 200px;
762
+ max-width: 100%;
763
+ max-height: 400px;
764
+ object-fit: contain;
765
+ border-radius: var(--smrt-radius-md, 6px);
766
+ border: 1px solid var(--smrt-color-outline-variant, #333);
767
+ background-color: var(--smrt-color-surface-container-high, #242424);
768
+ }
769
+
770
+ .confirm-info {
771
+ padding: 1rem 1.5rem 0.5rem;
772
+ display: flex;
773
+ flex-direction: column;
774
+ gap: 0.25rem;
775
+ }
776
+
777
+ .confirm-name {
778
+ font-weight: var(--smrt-typography-weight-medium, 500);
779
+ font-size: var(--smrt-typography-title-medium-size, 1.05rem);
780
+ }
781
+
782
+ .confirm-meta {
783
+ font-size: var(--smrt-typography-label-large-size, 0.85rem);
784
+ color: var(--smrt-color-outline, #888);
785
+ }
786
+
787
+ .confirm-actions {
788
+ padding: 1rem 1.5rem 1.5rem;
789
+ display: flex;
790
+ gap: 0.75rem;
791
+ flex-wrap: wrap;
792
+ border-top: 1px solid var(--smrt-color-outline-variant, #333);
793
+ margin-top: 0.5rem;
794
+ }
795
+
796
+ .primary-btn {
797
+ background: var(--smrt-color-primary, #3b82f6);
798
+ color: white;
799
+ border: none;
800
+ padding: 0.65rem 1.5rem;
801
+ border-radius: var(--smrt-radius-full, 9999px);
802
+ font-weight: var(--smrt-typography-weight-medium, 500);
803
+ cursor: pointer;
804
+ transition: filter 0.15s;
805
+ }
806
+
807
+ .primary-btn:hover {
808
+ filter: brightness(1.1);
809
+ }
810
+
811
+ .variation-toggle {
812
+ display: flex;
813
+ align-items: center;
814
+ gap: 0.4rem;
815
+ background: var(--smrt-color-surface-container-highest, #333);
816
+ color: var(--smrt-color-on-surface-variant, #ccc);
817
+ border: 1px solid var(--smrt-color-outline-variant, #444);
818
+ padding: 0.65rem 1.25rem;
819
+ border-radius: var(--smrt-radius-full, 9999px);
820
+ font-weight: var(--smrt-typography-weight-medium, 500);
821
+ cursor: pointer;
822
+ transition: all 0.2s;
823
+ }
824
+
825
+ .variation-toggle:hover {
826
+ background: var(--smrt-color-surface-container-high, #3f3f3f);
827
+ }
828
+
829
+ .variation-toggle.active {
830
+ color: var(--smrt-color-primary, #3b82f6);
831
+ border-color: var(--smrt-color-primary, #3b82f6);
832
+ background: color-mix(in srgb, var(--smrt-color-primary) 8%, transparent);
833
+ }
834
+
835
+ /* --- Variation Form --- */
836
+ .variation-form {
837
+ display: flex;
838
+ flex-direction: column;
839
+ gap: 0.75rem;
840
+ padding: 1.25rem;
841
+ margin: 0 1.5rem 1.5rem;
842
+ background: var(--smrt-color-surface-container-high, #242424);
843
+ border-radius: var(--smrt-radius-md, 6px);
844
+ border: 1px solid var(--smrt-color-outline-variant, #333);
845
+ }
846
+
847
+ .variation-hint {
848
+ font-size: var(--smrt-typography-body-medium-size, 0.85rem);
849
+ color: var(--smrt-color-outline, #888);
850
+ margin: 0;
851
+ }
852
+
853
+ .variation-form textarea {
854
+ width: 100%;
855
+ padding: 0.75rem;
856
+ background: var(--smrt-color-surface-container, #1a1a1a);
857
+ border: 1px solid var(--smrt-color-outline-variant, #444);
858
+ border-radius: var(--smrt-radius-sm, 4px);
859
+ color: inherit;
860
+ font-family: inherit;
861
+ font-size: var(--smrt-typography-body-large-size, 0.95rem);
862
+ resize: vertical;
863
+ transition: border-color 0.2s, box-shadow 0.2s;
864
+ }
865
+
866
+ .variation-form textarea:focus {
867
+ outline: none;
868
+ border-color: var(--smrt-color-primary, #3b82f6);
869
+ box-shadow: inset 0 0 0 1px var(--smrt-color-primary, #3b82f6);
870
+ }
871
+
872
+ .variation-form textarea:disabled {
873
+ opacity: 0.6;
874
+ }
875
+
876
+ .variation-error {
877
+ color: var(--smrt-color-error, #ef4444);
878
+ background: color-mix(in srgb, var(--smrt-color-error) 10%, transparent);
879
+ padding: 0.5rem 0.75rem;
880
+ border-radius: var(--smrt-radius-sm, 4px);
881
+ font-size: var(--smrt-typography-body-medium-size, 0.85rem);
882
+ }
883
+
884
+ .generate-btn {
885
+ display: flex;
886
+ align-items: center;
887
+ justify-content: center;
888
+ gap: 0.5rem;
889
+ align-self: flex-start;
890
+ background: var(--smrt-color-primary, #3b82f6);
891
+ color: white;
892
+ border: none;
893
+ padding: 0.65rem 1.5rem;
894
+ border-radius: var(--smrt-radius-full, 9999px);
895
+ font-weight: var(--smrt-typography-weight-medium, 500);
896
+ cursor: pointer;
897
+ transition: filter 0.15s, opacity 0.15s;
898
+ }
899
+
900
+ .generate-btn:hover:not(:disabled) {
901
+ filter: brightness(1.1);
902
+ }
903
+
904
+ .generate-btn:disabled {
905
+ opacity: 0.5;
906
+ cursor: not-allowed;
907
+ }
908
+
909
+ @keyframes spin {
910
+ to { transform: rotate(360deg); }
911
+ }
912
+
913
+ .spinner {
914
+ display: inline-block;
915
+ width: 14px;
916
+ height: 14px;
917
+ border: 2px solid color-mix(in srgb, var(--smrt-color-on-primary) 30%, transparent);
918
+ border-top-color: white;
919
+ border-radius: var(--smrt-radius-full, 9999px);
920
+ animation: spin 0.6s linear infinite;
921
+ }
922
+ </style>