@joewinke/jatui 0.21.0 → 0.23.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 (47) hide show
  1. package/CLAUDE.md +47 -1
  2. package/dist/actions/reveal.d.ts +37 -0
  3. package/dist/actions/reveal.js +42 -0
  4. package/dist/components/AudioRecorder.svelte +749 -0
  5. package/dist/components/AudioRecorder.svelte.d.ts +36 -0
  6. package/dist/components/IconSelector.svelte +417 -0
  7. package/dist/components/IconSelector.svelte.d.ts +55 -0
  8. package/dist/components/InfiniteGrid.svelte +305 -0
  9. package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
  10. package/dist/components/InfiniteSearchGrid.svelte +131 -0
  11. package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte +40 -3
  13. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +31 -16
  14. package/dist/components/MediaCaptureIcon.svelte +23 -0
  15. package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
  16. package/dist/components/ThumbnailGrid.svelte +137 -0
  17. package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
  18. package/dist/components/batch/BatchJobTable.svelte +549 -0
  19. package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
  20. package/dist/components/batch/QuotaMeter.svelte +78 -0
  21. package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
  22. package/dist/components/batch/batchQueue.d.ts +56 -0
  23. package/dist/components/batch/batchQueue.js +81 -0
  24. package/dist/components/batch/elapsedEstimate.d.ts +24 -0
  25. package/dist/components/batch/elapsedEstimate.js +25 -0
  26. package/dist/components/batch/pollOperation.d.ts +45 -0
  27. package/dist/components/batch/pollOperation.js +80 -0
  28. package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
  29. package/dist/components/batch/useElapsedEstimate.js +38 -0
  30. package/dist/components/infiniteGridLoader.d.ts +70 -0
  31. package/dist/components/infiniteGridLoader.js +141 -0
  32. package/dist/components/table/columnResize.d.ts +38 -0
  33. package/dist/components/table/columnResize.js +117 -0
  34. package/dist/components/table/index.d.ts +2 -0
  35. package/dist/components/table/index.js +1 -0
  36. package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
  37. package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
  38. package/dist/components/variant/variantCombinations.d.ts +75 -0
  39. package/dist/components/variant/variantCombinations.js +100 -0
  40. package/dist/history.d.ts +44 -0
  41. package/dist/history.js +87 -0
  42. package/dist/index.d.ts +30 -0
  43. package/dist/index.js +27 -0
  44. package/dist/styles/motion.css +100 -0
  45. package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
  46. package/dist/utils/keyboardShortcutRegistry.js +365 -0
  47. package/package.json +5 -1
@@ -0,0 +1,749 @@
1
+ <script module lang="ts">
2
+ /** What a finished recording hands back to the host — mirrors MediaCaptureResult's shape for the audio kind. */
3
+ export interface AudioRecordingResult {
4
+ blob: Blob
5
+ mimeType: string
6
+ durationSeconds: number
7
+ sizeBytes: number
8
+ }
9
+
10
+ /** m:ss zero-padded seconds — shared format for the timer display. */
11
+ export function formatDuration(totalSeconds: number): string {
12
+ const mins = Math.floor(totalSeconds / 60)
13
+ const secs = Math.floor(totalSeconds % 60)
14
+ return `${mins}:${secs.toString().padStart(2, '0')}`
15
+ }
16
+
17
+ /** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
18
+ export function formatFileSize(bytes: number): string {
19
+ if (bytes <= 0) return '0 B'
20
+ const k = 1024
21
+ const sizes = ['B', 'KB', 'MB', 'GB']
22
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
23
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
24
+ }
25
+ </script>
26
+
27
+ <script lang="ts">
28
+ /**
29
+ * AudioRecorder — voice-memo recorder with live waveform, ported from
30
+ * chimaro's chat/AudioRecorder.svelte (jatui-m4uz3.5).
31
+ *
32
+ * Completes jatui's audio trio alongside AudioWaveform (playback) and
33
+ * SpeechForm (voice-to-text input): this one is the CAPTURE surface.
34
+ *
35
+ * Ported as a host-agnostic primitive — everything chimaro-specific is
36
+ * decoupled into emitted callbacks:
37
+ * • no promptStore — the custom-prompt textarea was host state, not
38
+ * recorder state, and lives with the host;
39
+ * • no debugConfig — console chatter replaced by callback props;
40
+ * • no supabase template loading / analysis panel — hosts that want
41
+ * post-recording transcription or analysis consume `onRecordStop`'s
42
+ * blob payload and run their own pipeline (MardukOS Scribe does).
43
+ *
44
+ * What it owns: permission requests (with an explicit denied state),
45
+ * MIME negotiation, record/pause/resume/stop/cancel state machine, the
46
+ * theme-aware canvas waveform + level meter, and size/duration readouts.
47
+ *
48
+ * State machine: idle → recording ⇄ paused → processing → done, with
49
+ * cancel from any recording phase returning straight to idle. `reset()`
50
+ * returns a finished recorder to idle so the host can record again.
51
+ *
52
+ * Timer discipline (lesson from MediaCapture, mos-ow4q4.37): elapsed time
53
+ * is derived from the wall clock minus accumulated pause, never an
54
+ * incremented counter — a throttled background tab can't freeze it.
55
+ */
56
+ import { onDestroy, onMount } from 'svelte'
57
+ import { getDaisyUIColor, watchThemeChanges } from '../utils/daisyuiColors'
58
+ import MediaCaptureIcon from './MediaCaptureIcon.svelte'
59
+
60
+ interface Props {
61
+ /** `small` — inline chat-style pill (default). `large` — full recorder panel with waveform canvas. */
62
+ variant?: 'small' | 'large'
63
+ /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
64
+ maxDurationMs?: number
65
+ disabled?: boolean
66
+ /** Fired once the mic stream is live and recording has started. */
67
+ onRecordStart?: () => void
68
+ onRecordPause?: () => void
69
+ onRecordResume?: () => void
70
+ /** Fired with the finished recording. May return a Promise — the
71
+ * component stays in "processing" until it settles, so hosts can
72
+ * upload/transcribe without the user re-triggering a recording. */
73
+ onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>
74
+ /** Fired when the user cancels — no blob is produced. */
75
+ onRecordCancel?: () => void
76
+ /** Fired on recorder errors (permission denied, no mic, start failure). */
77
+ onError?: (message: string) => void
78
+ class?: string
79
+ }
80
+
81
+ let {
82
+ variant = 'small',
83
+ maxDurationMs = 0,
84
+ disabled = false,
85
+ onRecordStart,
86
+ onRecordPause,
87
+ onRecordResume,
88
+ onRecordStop,
89
+ onRecordCancel,
90
+ onError,
91
+ class: className = ''
92
+ }: Props = $props()
93
+
94
+ type Phase = 'idle' | 'recording' | 'processing' | 'done'
95
+ type PauseState = 'active' | 'paused'
96
+
97
+ let phase = $state<Phase>('idle')
98
+ let paused = $state<PauseState>('active')
99
+ let permissionDenied = $state(false)
100
+ let errorMessage = $state('')
101
+ let elapsedSec = $state(0)
102
+ let sizeBytes = $state(0)
103
+ let audioLevel = $state(0)
104
+
105
+ // Recorder internals
106
+ let recorder: MediaRecorder | null = null
107
+ let stream: MediaStream | null = null
108
+ let audioCtx: AudioContext | null = null
109
+ let analyser: AnalyserNode | null = null
110
+ let chunks: Blob[] = []
111
+ let mimeType = ''
112
+ let startedAt = 0
113
+ let pausedTotalMs = 0
114
+ let pauseStartedAt = 0
115
+ let raf: number | null = null
116
+ let tickTimer: ReturnType<typeof setInterval> | null = null
117
+ let autoStopTimer: ReturnType<typeof setTimeout> | null = null
118
+ let wasCancelled = false
119
+
120
+ // Canvas
121
+ let canvas = $state<HTMLCanvasElement | null>(null)
122
+ let canvasCtx: CanvasRenderingContext2D | null = null
123
+ let canvasColors = { background: '', centerLine: '', primary: '', secondary: '', dots: '' }
124
+
125
+ const hasUserMedia =
126
+ typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
127
+ const hasRecorder = typeof MediaRecorder !== 'undefined'
128
+ const supported = hasRecorder && hasUserMedia
129
+
130
+ function resolveColor(name: string, fallback: string): string {
131
+ if (typeof window === 'undefined') return fallback
132
+ try {
133
+ const c = getDaisyUIColor(name)
134
+ return c && c !== 'undefined' && c !== '' ? c : fallback
135
+ } catch {
136
+ return fallback
137
+ }
138
+ }
139
+
140
+ function updateCanvasColors() {
141
+ canvasColors = {
142
+ background: resolveColor('base-200', '#1f2937'),
143
+ centerLine: resolveColor('base-300', '#374151'),
144
+ primary: resolveColor('primary', '#3b82f6'),
145
+ secondary: resolveColor('success', '#10b981'),
146
+ dots: resolveColor('base-content', '#6b7280')
147
+ }
148
+ }
149
+
150
+ function pickMime(): string {
151
+ for (const c of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']) {
152
+ if (MediaRecorder.isTypeSupported(c)) return c
153
+ }
154
+ return ''
155
+ }
156
+
157
+ /** Elapsed from the wall clock, pause-adjusted — see header note. */
158
+ function elapsedMs(): number {
159
+ if (!startedAt) return 0
160
+ const pausedMs = pausedTotalMs + (pauseStartedAt ? Date.now() - pauseStartedAt : 0)
161
+ return Math.max(0, Date.now() - startedAt - pausedMs)
162
+ }
163
+
164
+ function tick() {
165
+ elapsedSec = Math.floor(elapsedMs() / 1000)
166
+ }
167
+
168
+ function stopAllTracks() {
169
+ stream?.getTracks().forEach((t) => t.stop())
170
+ stream = null
171
+ }
172
+
173
+ function stopAnimation() {
174
+ if (raf !== null) {
175
+ cancelAnimationFrame(raf)
176
+ raf = null
177
+ }
178
+ }
179
+
180
+ function clearTimers() {
181
+ if (tickTimer) {
182
+ clearInterval(tickTimer)
183
+ tickTimer = null
184
+ }
185
+ if (autoStopTimer) {
186
+ clearTimeout(autoStopTimer)
187
+ autoStopTimer = null
188
+ }
189
+ }
190
+
191
+ /** Tear down every media resource. Idempotent — safe from cancel/stop/destroy. */
192
+ function cleanup() {
193
+ wasCancelled = true
194
+ if (recorder && recorder.state !== 'inactive') {
195
+ recorder.ondataavailable = null
196
+ recorder.onstop = null
197
+ try {
198
+ recorder.stop()
199
+ } catch {
200
+ // already inactive
201
+ }
202
+ }
203
+ recorder = null
204
+ stopAllTracks()
205
+ if (audioCtx && audioCtx.state !== 'closed') {
206
+ audioCtx.close().catch(() => {})
207
+ }
208
+ audioCtx = null
209
+ analyser = null
210
+ stopAnimation()
211
+ clearTimers()
212
+ chunks = []
213
+ sizeBytes = 0
214
+ audioLevel = 0
215
+ startedAt = 0
216
+ pausedTotalMs = 0
217
+ pauseStartedAt = 0
218
+ elapsedSec = 0
219
+ paused = 'active'
220
+ clearCanvas()
221
+ }
222
+
223
+ function sizeCanvas() {
224
+ if (!canvas) return
225
+ const parent = canvas.parentElement
226
+ const width = Math.max(160, Math.min(parent?.clientWidth ?? 480, 800))
227
+ const height = 80
228
+ if (canvas.width !== width || canvas.height !== height) {
229
+ canvas.width = width
230
+ canvas.height = height
231
+ }
232
+ clearCanvas()
233
+ }
234
+
235
+ function clearCanvas() {
236
+ if (!canvas || !canvasCtx) return
237
+ const { width, height } = canvas
238
+ canvasCtx.clearRect(0, 0, width, height)
239
+ canvasCtx.fillStyle = canvasColors.background
240
+ canvasCtx.fillRect(0, 0, width, height)
241
+ canvasCtx.strokeStyle = canvasColors.centerLine
242
+ canvasCtx.lineWidth = 1
243
+ canvasCtx.beginPath()
244
+ canvasCtx.moveTo(0, height / 2)
245
+ canvasCtx.lineTo(width, height / 2)
246
+ canvasCtx.stroke()
247
+ }
248
+
249
+ function drawIdleDots() {
250
+ if (!canvas || !canvasCtx) return
251
+ const { width, height } = canvas
252
+ const count = Math.floor(width / 20)
253
+ const spacing = width / (count + 1)
254
+ const t = Date.now() * 0.003
255
+ canvasCtx.fillStyle = canvasColors.dots
256
+ for (let i = 0; i < count; i++) {
257
+ const pulse = Math.sin(t + i * 0.5) * 0.3 + 0.7
258
+ canvasCtx.beginPath()
259
+ canvasCtx.arc((i + 1) * spacing, height / 2, 1.5 * pulse, 0, Math.PI * 2)
260
+ canvasCtx.fill()
261
+ }
262
+ }
263
+
264
+ function drawWaveform(freq: Uint8Array) {
265
+ if (!canvas || !canvasCtx) return
266
+ const { width, height } = canvas
267
+ canvasCtx.fillStyle = canvasColors.background
268
+ canvasCtx.fillRect(0, 0, width, height)
269
+ canvasCtx.strokeStyle = canvasColors.centerLine
270
+ canvasCtx.lineWidth = 1
271
+ canvasCtx.beginPath()
272
+ canvasCtx.moveTo(0, height / 2)
273
+ canvasCtx.lineTo(width, height / 2)
274
+ canvasCtx.stroke()
275
+
276
+ const barCount = 60
277
+ const gap = 1
278
+ const barWidth = Math.max(1, Math.floor((width - (barCount - 1) * gap) / barCount))
279
+ const centerY = height / 2
280
+ const usable = Math.floor(freq.length * 0.75)
281
+ const perBar = Math.max(1, Math.floor(usable / barCount))
282
+
283
+ const gradient = canvasCtx.createLinearGradient(0, 0, 0, height)
284
+ gradient.addColorStop(0, canvasColors.primary)
285
+ gradient.addColorStop(0.5, canvasColors.secondary)
286
+ gradient.addColorStop(1, canvasColors.primary)
287
+ canvasCtx.fillStyle = gradient
288
+
289
+ for (let i = 0; i < barCount; i++) {
290
+ let sum = 0
291
+ for (let j = 0; j < perBar; j++) {
292
+ const idx = i * perBar + j
293
+ if (idx < usable) sum += freq[idx]
294
+ }
295
+ const amplified = (sum / perBar / 255) * 1.5
296
+ const h = Math.max(amplified * (height / 2 - 2), 0.5)
297
+ canvasCtx.fillRect(i * (barWidth + gap), centerY - h, barWidth, h * 2)
298
+ }
299
+ }
300
+
301
+ function animate() {
302
+ if (!analyser || phase !== 'recording') return
303
+ if (paused === 'paused') {
304
+ if (canvas) {
305
+ sizeCanvasIfNeeded()
306
+ clearCanvas()
307
+ drawIdleDots()
308
+ }
309
+ audioLevel = 0
310
+ raf = requestAnimationFrame(animate)
311
+ return
312
+ }
313
+ const freq = new Uint8Array(analyser.frequencyBinCount)
314
+ const time = new Uint8Array(analyser.frequencyBinCount)
315
+ analyser.getByteFrequencyData(freq)
316
+ analyser.getByteTimeDomainData(time)
317
+ let sum = 0
318
+ for (let i = 0; i < time.length; i++) {
319
+ const s = (time[i] - 128) / 128
320
+ sum += s * s
321
+ }
322
+ audioLevel = Math.min(100, Math.sqrt(sum / time.length) * 300)
323
+ if (canvas) {
324
+ sizeCanvasIfNeeded()
325
+ if (freq.some((v) => v > 0)) drawWaveform(freq)
326
+ else {
327
+ clearCanvas()
328
+ drawIdleDots()
329
+ }
330
+ }
331
+ raf = requestAnimationFrame(animate)
332
+ }
333
+
334
+ let lastCanvasWidth = 0
335
+ /** Re-measure the canvas when its container resizes, without churning every frame. */
336
+ function sizeCanvasIfNeeded() {
337
+ if (!canvas || !canvas.parentElement) return
338
+ const w = Math.max(160, Math.min(canvas.parentElement.clientWidth, 800))
339
+ if (w !== lastCanvasWidth) {
340
+ lastCanvasWidth = w
341
+ sizeCanvas()
342
+ }
343
+ }
344
+
345
+ async function start() {
346
+ if (!supported || disabled || phase === 'recording' || phase === 'processing') return
347
+ errorMessage = ''
348
+ permissionDenied = false
349
+ try {
350
+ stream = await navigator.mediaDevices.getUserMedia({
351
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
352
+ })
353
+ mimeType = pickMime()
354
+ recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
355
+ mimeType = recorder.mimeType || mimeType || 'audio/webm'
356
+ chunks = []
357
+ sizeBytes = 0
358
+ wasCancelled = false
359
+
360
+ recorder.ondataavailable = (e) => {
361
+ if (e.data.size > 0) {
362
+ chunks.push(e.data)
363
+ sizeBytes += e.data.size
364
+ }
365
+ }
366
+ recorder.onstop = () => {
367
+ void finalize()
368
+ }
369
+
370
+ // Waveform analyser — visualization only, never touches the recorded data.
371
+ audioCtx = new AudioContext()
372
+ analyser = audioCtx.createAnalyser()
373
+ analyser.fftSize = 512
374
+ analyser.smoothingTimeConstant = 0.3
375
+ analyser.minDecibels = -100
376
+ analyser.maxDecibels = -20
377
+ audioCtx.createMediaStreamSource(stream).connect(analyser)
378
+
379
+ recorder.start(1000)
380
+ startedAt = Date.now()
381
+ pausedTotalMs = 0
382
+ pauseStartedAt = 0
383
+ elapsedSec = 0
384
+ paused = 'active'
385
+ phase = 'recording'
386
+ tickTimer = setInterval(tick, 250)
387
+ if (maxDurationMs > 0) {
388
+ autoStopTimer = setTimeout(stop, maxDurationMs)
389
+ }
390
+ updateCanvasColors()
391
+ // Canvas mounts with the recording UI — give Svelte a tick, then go live.
392
+ setTimeout(() => {
393
+ if (canvas) {
394
+ canvasCtx = canvas.getContext('2d')
395
+ lastCanvasWidth = 0
396
+ sizeCanvas()
397
+ }
398
+ stopAnimation()
399
+ raf = requestAnimationFrame(animate)
400
+ }, 0)
401
+ onRecordStart?.()
402
+ } catch (err) {
403
+ cleanup()
404
+ phase = 'idle'
405
+ const name = (err as DOMException)?.name ?? ''
406
+ let message: string
407
+ if (name === 'NotAllowedError' || name === 'SecurityError') {
408
+ permissionDenied = true
409
+ message = 'Microphone access was denied. Allow mic permission in your browser settings and try again.'
410
+ } else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
411
+ message = 'No microphone was found. Connect one and try again.'
412
+ } else {
413
+ message = 'Failed to start recording. Check the microphone and try again.'
414
+ }
415
+ errorMessage = message
416
+ onError?.(message)
417
+ }
418
+ }
419
+
420
+ function pause() {
421
+ if (phase !== 'recording' || paused === 'paused' || !recorder) return
422
+ try {
423
+ recorder.pause()
424
+ paused = 'paused'
425
+ pauseStartedAt = Date.now()
426
+ stopAnimation()
427
+ onRecordPause?.()
428
+ } catch {
429
+ // recorder already inactive — nothing to pause
430
+ }
431
+ }
432
+
433
+ function resume() {
434
+ if (phase !== 'recording' || paused !== 'paused' || !recorder) return
435
+ try {
436
+ recorder.resume()
437
+ pausedTotalMs += Date.now() - pauseStartedAt
438
+ pauseStartedAt = 0
439
+ paused = 'active'
440
+ stopAnimation()
441
+ raf = requestAnimationFrame(animate)
442
+ onRecordResume?.()
443
+ } catch {
444
+ // recorder already inactive — nothing to resume
445
+ }
446
+ }
447
+
448
+ function stop() {
449
+ if (phase !== 'recording' || !recorder) return
450
+ if (pauseStartedAt) {
451
+ pausedTotalMs += Date.now() - pauseStartedAt
452
+ pauseStartedAt = 0
453
+ }
454
+ clearTimers()
455
+ stopAnimation()
456
+ if (recorder.state !== 'inactive') {
457
+ phase = 'processing'
458
+ recorder.stop()
459
+ }
460
+ }
461
+
462
+ function cancel() {
463
+ if (phase !== 'recording') return
464
+ cleanup()
465
+ phase = 'idle'
466
+ onRecordCancel?.()
467
+ }
468
+
469
+ async function finalize() {
470
+ if (wasCancelled || chunks.length === 0) {
471
+ if (!wasCancelled) {
472
+ // Zero chunks (e.g. stopped before any data arrived) — fail loud, not a silent no-op.
473
+ phase = 'idle'
474
+ errorMessage = 'Recording was too short — nothing was captured. Try again.'
475
+ onError?.(errorMessage)
476
+ }
477
+ return
478
+ }
479
+ phase = 'processing'
480
+ const blob = new Blob(chunks, { type: mimeType })
481
+ const result: AudioRecordingResult = {
482
+ blob,
483
+ mimeType,
484
+ durationSeconds: Math.max(1, Math.round(elapsedMs() / 1000)),
485
+ sizeBytes: blob.size
486
+ }
487
+ cleanup()
488
+ phase = 'done'
489
+ if (onRecordStop) {
490
+ try {
491
+ await onRecordStop(result)
492
+ } catch (err) {
493
+ errorMessage = `Recording kept, but the handler failed: ${err instanceof Error ? err.message : String(err)}`
494
+ onError?.(errorMessage)
495
+ }
496
+ }
497
+ }
498
+
499
+ /** Return a finished/errored recorder to idle so the host can record again. */
500
+ export function reset() {
501
+ cleanup()
502
+ phase = 'idle'
503
+ errorMessage = ''
504
+ permissionDenied = false
505
+ }
506
+
507
+ function retryAfterError() {
508
+ errorMessage = ''
509
+ permissionDenied = false
510
+ phase = 'idle'
511
+ }
512
+
513
+ onMount(() => {
514
+ updateCanvasColors()
515
+ const unwatch = watchThemeChanges(() => {
516
+ updateCanvasColors()
517
+ clearCanvas()
518
+ })
519
+ const onResize = () => {
520
+ lastCanvasWidth = 0
521
+ if (canvas) sizeCanvas()
522
+ }
523
+ window.addEventListener('resize', onResize)
524
+ return () => {
525
+ window.removeEventListener('resize', onResize)
526
+ unwatch()
527
+ }
528
+ })
529
+
530
+ // Space toggles pause/resume while recording (chimaro behaviour).
531
+ function handleKeydown(e: KeyboardEvent) {
532
+ if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
533
+ e.preventDefault()
534
+ if (paused === 'paused') resume()
535
+ else pause()
536
+ }
537
+ }
538
+
539
+ onDestroy(() => {
540
+ cleanup()
541
+ })
542
+ </script>
543
+
544
+ <svelte:document onkeydown={handleKeydown} />
545
+
546
+ <div class={className}>
547
+ {#if variant === 'large'}
548
+ <div class="flex flex-col items-center gap-3">
549
+ {#if phase === 'processing'}
550
+ <div class="badge badge-primary badge-lg gap-2">
551
+ <span class="loading loading-spinner loading-xs"></span>
552
+ Processing…
553
+ </div>
554
+ {:else if phase === 'done'}
555
+ <div class="badge badge-success badge-lg">✓ Complete</div>
556
+ {:else if phase === 'recording' && paused === 'paused'}
557
+ <div class="badge badge-warning badge-lg">⏸ Paused</div>
558
+ {:else if phase === 'recording'}
559
+ <div class="badge badge-error badge-lg animate-pulse">● Recording</div>
560
+ {:else}
561
+ <div class="badge badge-neutral badge-lg">Ready to Record</div>
562
+ {/if}
563
+
564
+ {#if phase === 'recording' || phase === 'processing'}
565
+ <div class="font-mono text-5xl font-bold">{formatDuration(elapsedSec)}</div>
566
+ <div class="text-base-content/60 font-mono text-sm">{formatFileSize(sizeBytes)}</div>
567
+ {/if}
568
+
569
+ {#if phase === 'recording'}
570
+ <div class="relative flex min-h-[96px] items-center justify-center">
571
+ {#if paused !== 'paused'}
572
+ <div class="audio-recorder-pulse"></div>
573
+ {/if}
574
+ <canvas bind:this={canvas} class="border-base-300 bg-base-100 z-10 rounded border"></canvas>
575
+ </div>
576
+ <div class="flex min-h-[20px] items-center gap-2">
577
+ <span class="text-base-content/60 text-xs">Volume:</span>
578
+ <div class="bg-base-300 h-1 w-16 overflow-hidden rounded-full">
579
+ <div
580
+ class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
581
+ style="width: {Math.min(audioLevel, 100)}%"
582
+ ></div>
583
+ </div>
584
+ </div>
585
+ <div class="mt-2 flex items-center gap-3">
586
+ {#if paused === 'paused'}
587
+ <button
588
+ type="button"
589
+ class="btn btn-success btn-circle"
590
+ onclick={resume}
591
+ aria-label="Resume recording"
592
+ >
593
+ <MediaCaptureIcon name="play" size={20} class="fill-current" />
594
+ </button>
595
+ {:else}
596
+ <button
597
+ type="button"
598
+ class="btn btn-warning btn-circle"
599
+ onclick={pause}
600
+ aria-label="Pause recording"
601
+ >
602
+ <MediaCaptureIcon name="pause" size={20} />
603
+ </button>
604
+ {/if}
605
+ <button
606
+ type="button"
607
+ class="btn btn-primary btn-circle"
608
+ onclick={stop}
609
+ aria-label="Stop and save recording"
610
+ >
611
+ <MediaCaptureIcon name="square" size={20} class="fill-current" />
612
+ </button>
613
+ </div>
614
+ <button type="button" class="btn btn-ghost btn-sm text-error" onclick={cancel}>
615
+ <MediaCaptureIcon name="x" size={12} />
616
+ Cancel
617
+ </button>
618
+ <p class="text-base-content/45 text-xs">Space pauses / resumes</p>
619
+ {:else if phase === 'idle'}
620
+ <button
621
+ type="button"
622
+ class="btn btn-error btn-circle btn-xl hover:btn-primary transition-all"
623
+ {disabled}
624
+ onclick={start}
625
+ aria-label="Start voice recording"
626
+ >
627
+ <MediaCaptureIcon name="mic" size={24} />
628
+ </button>
629
+ {/if}
630
+
631
+ {#if errorMessage}
632
+ <div class="alert alert-warning w-full max-w-md text-sm" role="status" aria-live="polite">
633
+ <span>{errorMessage}</span>
634
+ {#if phase === 'idle'}
635
+ <button type="button" class="btn btn-xs" onclick={retryAfterError}>Dismiss</button>
636
+ {/if}
637
+ </div>
638
+ {/if}
639
+ {#if permissionDenied}
640
+ <p class="text-base-content/50 max-w-md text-center text-xs">
641
+ Your browser blocked microphone access. Enable it from the lock icon in the address bar
642
+ (or site settings), then press the record button again.
643
+ </p>
644
+ {/if}
645
+ </div>
646
+ {:else}
647
+ <!-- small: chat-inline pill -->
648
+ <div class="flex items-center gap-2">
649
+ {#if phase === 'idle'}
650
+ {#if supported}
651
+ <button
652
+ type="button"
653
+ class="btn btn-ghost btn-circle btn-sm hover:btn-primary transition-all"
654
+ {disabled}
655
+ onclick={start}
656
+ aria-label="Start voice recording"
657
+ title="Start voice recording"
658
+ >
659
+ <MediaCaptureIcon name="mic" size={14} />
660
+ </button>
661
+ {/if}
662
+ {:else if phase === 'recording'}
663
+ <div class="bg-base-100 border-base-200 flex items-center gap-2 rounded-lg border p-1.5 shadow-sm">
664
+ {#if paused === 'paused'}
665
+ <button
666
+ type="button"
667
+ class="btn btn-success btn-circle btn-sm"
668
+ onclick={resume}
669
+ aria-label="Resume recording"
670
+ >
671
+ <MediaCaptureIcon name="play" size={14} class="fill-current" />
672
+ </button>
673
+ {:else}
674
+ <button
675
+ type="button"
676
+ class="btn btn-warning btn-circle btn-sm"
677
+ onclick={pause}
678
+ aria-label="Pause recording"
679
+ >
680
+ <MediaCaptureIcon name="pause" size={14} />
681
+ </button>
682
+ {/if}
683
+ <button
684
+ type="button"
685
+ class="btn btn-primary btn-circle btn-sm"
686
+ onclick={stop}
687
+ aria-label="Stop and save recording"
688
+ >
689
+ <MediaCaptureIcon name="square" size={14} class="fill-current" />
690
+ </button>
691
+ <span class="text-base-content/60 w-9 text-center font-mono text-xs">
692
+ {formatDuration(elapsedSec)}
693
+ </span>
694
+ <div class="bg-base-300 h-1 w-8 overflow-hidden rounded-full">
695
+ <div
696
+ class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
697
+ style="width: {Math.min(audioLevel, 100)}%"
698
+ ></div>
699
+ </div>
700
+ <button
701
+ type="button"
702
+ class="btn btn-ghost btn-xs text-error"
703
+ onclick={cancel}
704
+ aria-label="Cancel recording"
705
+ >
706
+ <MediaCaptureIcon name="x" size={10} />
707
+ </button>
708
+ </div>
709
+ {:else if phase === 'processing'}
710
+ <div class="flex items-center gap-2" role="status" aria-live="polite">
711
+ <MediaCaptureIcon name="loader" size={14} class="animate-spin" />
712
+ <span class="text-base-content/60 text-xs">Processing…</span>
713
+ </div>
714
+ {:else if phase === 'done'}
715
+ <span class="text-success text-xs">✓ Saved</span>
716
+ <button type="button" class="btn btn-ghost btn-xs" onclick={reset}>Record again</button>
717
+ {/if}
718
+ {#if errorMessage && variant === 'small'}
719
+ <span class="text-warning text-xs" role="status" aria-live="polite">{errorMessage}</span>
720
+ {/if}
721
+ </div>
722
+ {/if}
723
+ </div>
724
+
725
+ <style>
726
+ .audio-recorder-pulse {
727
+ position: absolute;
728
+ inset: -8px;
729
+ border-radius: 12px;
730
+ background: color-mix(in oklch, var(--color-error) 10%, transparent);
731
+ animation: audio-recorder-pulse 2s ease-in-out infinite;
732
+ }
733
+ @keyframes audio-recorder-pulse {
734
+ 0%,
735
+ 100% {
736
+ transform: scale(1);
737
+ opacity: 0.3;
738
+ }
739
+ 50% {
740
+ transform: scale(1.05);
741
+ opacity: 0.6;
742
+ }
743
+ }
744
+ @media (prefers-reduced-motion: reduce) {
745
+ .audio-recorder-pulse {
746
+ animation: none;
747
+ }
748
+ }
749
+ </style>