@joewinke/jatui 0.22.0 → 0.24.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 (71) hide show
  1. package/CLAUDE.md +72 -2
  2. package/dist/actions/reveal.d.ts +37 -0
  3. package/dist/actions/reveal.js +42 -0
  4. package/dist/components/AudioRecorder.svelte +529 -0
  5. package/dist/components/AudioRecorder.svelte.d.ts +30 -0
  6. package/dist/components/BulkActionBar.svelte +40 -0
  7. package/dist/components/BulkActionBar.svelte.d.ts +9 -0
  8. package/dist/components/ConfirmDialog.svelte +39 -35
  9. package/dist/components/CopyButton.svelte +5 -2
  10. package/dist/components/FloatingActionBar.svelte +55 -1
  11. package/dist/components/FloatingActionBar.svelte.d.ts +8 -1
  12. package/dist/components/IconSelector.svelte +417 -0
  13. package/dist/components/IconSelector.svelte.d.ts +55 -0
  14. package/dist/components/InfiniteGrid.svelte +305 -0
  15. package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
  16. package/dist/components/InfiniteSearchGrid.svelte +131 -0
  17. package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
  18. package/dist/components/InputDialog.svelte +49 -38
  19. package/dist/components/KeyboardShortcutsOverlay.svelte +94 -3
  20. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +61 -16
  21. package/dist/components/MediaCaptureIcon.svelte +23 -0
  22. package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
  23. package/dist/components/ThumbnailGrid.svelte +137 -0
  24. package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
  25. package/dist/components/batch/BatchJobTable.svelte +549 -0
  26. package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
  27. package/dist/components/batch/QuotaMeter.svelte +78 -0
  28. package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
  29. package/dist/components/batch/batchQueue.d.ts +56 -0
  30. package/dist/components/batch/batchQueue.js +81 -0
  31. package/dist/components/batch/elapsedEstimate.d.ts +24 -0
  32. package/dist/components/batch/elapsedEstimate.js +25 -0
  33. package/dist/components/batch/pollOperation.d.ts +45 -0
  34. package/dist/components/batch/pollOperation.js +80 -0
  35. package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
  36. package/dist/components/batch/useElapsedEstimate.js +38 -0
  37. package/dist/components/bulkActionBarKeys.d.ts +40 -0
  38. package/dist/components/bulkActionBarKeys.js +38 -0
  39. package/dist/components/infiniteGridLoader.d.ts +70 -0
  40. package/dist/components/infiniteGridLoader.js +141 -0
  41. package/dist/components/linked-columns/LinkedColumns.svelte +10 -22
  42. package/dist/components/linked-columns/LinkedColumns.svelte.d.ts +1 -9
  43. package/dist/components/linked-columns/types.d.ts +9 -0
  44. package/dist/components/linked-columns/types.js +1 -0
  45. package/dist/components/replay/ChapterTimeline.svelte +43 -25
  46. package/dist/components/replay/ChapterTimeline.svelte.d.ts +26 -1
  47. package/dist/components/table/columnResize.d.ts +38 -0
  48. package/dist/components/table/columnResize.js +117 -0
  49. package/dist/components/table/index.d.ts +2 -0
  50. package/dist/components/table/index.js +1 -0
  51. package/dist/components/toast/ToastItem.svelte +47 -2
  52. package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
  53. package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
  54. package/dist/components/variant/variantCombinations.d.ts +75 -0
  55. package/dist/components/variant/variantCombinations.js +100 -0
  56. package/dist/index.d.ts +34 -3
  57. package/dist/index.js +30 -1
  58. package/dist/stores/toast.d.ts +8 -0
  59. package/dist/stores/toast.js +2 -0
  60. package/dist/styles/motion.css +100 -0
  61. package/dist/utils/audioRecorder.d.ts +28 -0
  62. package/dist/utils/audioRecorder.js +58 -0
  63. package/dist/utils/audioRecorderCore.d.ts +132 -0
  64. package/dist/utils/audioRecorderCore.js +447 -0
  65. package/dist/utils/clipboard.d.ts +25 -0
  66. package/dist/utils/clipboard.js +59 -0
  67. package/dist/utils/dateFormatters.d.ts +11 -2
  68. package/dist/utils/dateFormatters.js +22 -2
  69. package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
  70. package/dist/utils/keyboardShortcutRegistry.js +365 -0
  71. package/package.json +1 -1
@@ -0,0 +1,529 @@
1
+ <script module lang="ts">
2
+ // The capture pipeline lives in the headless factory (jatui-is2ow.8) —
3
+ // this component is its markup. Re-exported here so consumers importing
4
+ // the type from the component keep working.
5
+ export type { AudioRecordingResult } from '../utils/audioRecorderCore'
6
+
7
+ /** m:ss zero-padded seconds — shared format for the timer display. */
8
+ export function formatDuration(totalSeconds: number): string {
9
+ const mins = Math.floor(totalSeconds / 60)
10
+ const secs = Math.floor(totalSeconds % 60)
11
+ return `${mins}:${secs.toString().padStart(2, '0')}`
12
+ }
13
+
14
+ /** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
15
+ export function formatFileSize(bytes: number): string {
16
+ if (bytes <= 0) return '0 B'
17
+ const k = 1024
18
+ const sizes = ['B', 'KB', 'MB', 'GB']
19
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
20
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
21
+ }
22
+ </script>
23
+
24
+ <script lang="ts">
25
+ /**
26
+ * AudioRecorder — voice-memo recorder with live waveform, ported from
27
+ * chimaro's chat/AudioRecorder.svelte (jatui-m4uz3.5).
28
+ *
29
+ * Completes jatui's audio trio alongside AudioWaveform (playback) and
30
+ * SpeechForm (voice-to-text input): this one is the CAPTURE surface.
31
+ *
32
+ * Ported as a host-agnostic primitive — everything chimaro-specific is
33
+ * decoupled into emitted callbacks:
34
+ * • no promptStore — the custom-prompt textarea was host state, not
35
+ * recorder state, and lives with the host;
36
+ * • no debugConfig — console chatter replaced by callback props;
37
+ * • no supabase template loading / analysis panel — hosts that want
38
+ * post-recording transcription or analysis consume `onRecordStop`'s
39
+ * blob payload and run their own pipeline (MardukOS Scribe does).
40
+ *
41
+ * What it owns: markup only — the theme-aware canvas waveform + level
42
+ * meter, phase badges, and size/duration readouts. The capture pipeline
43
+ * itself (permission requests, MIME negotiation, the
44
+ * record/pause/resume/stop/cancel state machine, timing) lives in the
45
+ * headless createAudioRecorder() factory (jatui-is2ow.8) so hosts can
46
+ * drive the same pipeline from their own UI.
47
+ */
48
+ import { onDestroy, onMount } from 'svelte'
49
+ import { createAudioRecorder } from '../utils/audioRecorder'
50
+ import { getDaisyUIColor, watchThemeChanges } from '../utils/daisyuiColors'
51
+ import MediaCaptureIcon from './MediaCaptureIcon.svelte'
52
+
53
+ interface Props {
54
+ /** `small` — inline chat-style pill (default). `large` — full recorder panel with waveform canvas. */
55
+ variant?: 'small' | 'large'
56
+ /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
57
+ maxDurationMs?: number
58
+ disabled?: boolean
59
+ /** Fired once the mic stream is live and recording has started. */
60
+ onRecordStart?: () => void
61
+ onRecordPause?: () => void
62
+ onRecordResume?: () => void
63
+ /** Fired with the finished recording. May return a Promise — the
64
+ * component stays in "processing" until it settles, so hosts can
65
+ * upload/transcribe without the user re-triggering a recording. */
66
+ onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>
67
+ /** Fired when the user cancels — no blob is produced. */
68
+ onRecordCancel?: () => void
69
+ /** Fired on recorder errors (permission denied, no mic, start failure). */
70
+ onError?: (message: string) => void
71
+ class?: string
72
+ }
73
+
74
+ let {
75
+ variant = 'small',
76
+ maxDurationMs = 0,
77
+ disabled = false,
78
+ onRecordStart,
79
+ onRecordPause,
80
+ onRecordResume,
81
+ onRecordStop,
82
+ onRecordCancel,
83
+ onError,
84
+ class: className = ''
85
+ }: Props = $props()
86
+
87
+ type Phase = 'idle' | 'starting' | 'recording' | 'processing' | 'done'
88
+
89
+ let permissionDenied = $state(false)
90
+
91
+ // Headless capture core — all media handling, timing and state live there.
92
+ const rec = createAudioRecorder({
93
+ mode: 'toggle',
94
+ maxDurationMs,
95
+ onStart: onRecordStart,
96
+ onPause: onRecordPause,
97
+ onResume: onRecordResume,
98
+ onStop: onRecordStop,
99
+ onCancel: onRecordCancel,
100
+ onError: (err) => {
101
+ if (err.kind === 'denied') permissionDenied = true
102
+ onError?.(err.message)
103
+ },
104
+ // Canvas visualization — driven by the core's analyser frames.
105
+ onFrame: ({ frequency }) => {
106
+ if (!canvas) return
107
+ if (!canvasCtx) canvasCtx = canvas.getContext('2d')
108
+ sizeCanvasIfNeeded()
109
+ if (frequency.some((v) => v > 0)) drawWaveform(frequency)
110
+ else {
111
+ clearCanvas()
112
+ drawIdleDots()
113
+ }
114
+ }
115
+ })
116
+
117
+ const phase = $derived<Phase>(rec.phase)
118
+ const paused = $derived(rec.paused)
119
+ const elapsedSec = $derived(Math.floor(rec.elapsedMs / 1000))
120
+ const sizeBytes = $derived(rec.sizeBytes)
121
+ const audioLevel = $derived(rec.level)
122
+ const errorMessage = $derived(rec.error?.message ?? '')
123
+ const supported = $derived(rec.supported)
124
+
125
+ // Canvas
126
+ let canvas = $state<HTMLCanvasElement | null>(null)
127
+ let canvasCtx: CanvasRenderingContext2D | null = null
128
+ let canvasColors = { background: '', centerLine: '', primary: '', secondary: '', dots: '' }
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 sizeCanvas() {
151
+ if (!canvas) return
152
+ const parent = canvas.parentElement
153
+ const width = Math.max(160, Math.min(parent?.clientWidth ?? 480, 800))
154
+ const height = 80
155
+ if (canvas.width !== width || canvas.height !== height) {
156
+ canvas.width = width
157
+ canvas.height = height
158
+ }
159
+ clearCanvas()
160
+ }
161
+
162
+ function clearCanvas() {
163
+ if (!canvas || !canvasCtx) return
164
+ const { width, height } = canvas
165
+ canvasCtx.clearRect(0, 0, width, height)
166
+ canvasCtx.fillStyle = canvasColors.background
167
+ canvasCtx.fillRect(0, 0, width, height)
168
+ canvasCtx.strokeStyle = canvasColors.centerLine
169
+ canvasCtx.lineWidth = 1
170
+ canvasCtx.beginPath()
171
+ canvasCtx.moveTo(0, height / 2)
172
+ canvasCtx.lineTo(width, height / 2)
173
+ canvasCtx.stroke()
174
+ }
175
+
176
+ function drawIdleDots() {
177
+ if (!canvas || !canvasCtx) return
178
+ const { width, height } = canvas
179
+ const count = Math.floor(width / 20)
180
+ const spacing = width / (count + 1)
181
+ const t = Date.now() * 0.003
182
+ canvasCtx.fillStyle = canvasColors.dots
183
+ for (let i = 0; i < count; i++) {
184
+ const pulse = Math.sin(t + i * 0.5) * 0.3 + 0.7
185
+ canvasCtx.beginPath()
186
+ canvasCtx.arc((i + 1) * spacing, height / 2, 1.5 * pulse, 0, Math.PI * 2)
187
+ canvasCtx.fill()
188
+ }
189
+ }
190
+
191
+ function drawWaveform(freq: Uint8Array) {
192
+ if (!canvas || !canvasCtx) return
193
+ const { width, height } = canvas
194
+ canvasCtx.fillStyle = canvasColors.background
195
+ canvasCtx.fillRect(0, 0, width, height)
196
+ canvasCtx.strokeStyle = canvasColors.centerLine
197
+ canvasCtx.lineWidth = 1
198
+ canvasCtx.beginPath()
199
+ canvasCtx.moveTo(0, height / 2)
200
+ canvasCtx.lineTo(width, height / 2)
201
+ canvasCtx.stroke()
202
+
203
+ const barCount = 60
204
+ const gap = 1
205
+ const barWidth = Math.max(1, Math.floor((width - (barCount - 1) * gap) / barCount))
206
+ const centerY = height / 2
207
+ const usable = Math.floor(freq.length * 0.75)
208
+ const perBar = Math.max(1, Math.floor(usable / barCount))
209
+
210
+ const gradient = canvasCtx.createLinearGradient(0, 0, 0, height)
211
+ gradient.addColorStop(0, canvasColors.primary)
212
+ gradient.addColorStop(0.5, canvasColors.secondary)
213
+ gradient.addColorStop(1, canvasColors.primary)
214
+ canvasCtx.fillStyle = gradient
215
+
216
+ for (let i = 0; i < barCount; i++) {
217
+ let sum = 0
218
+ for (let j = 0; j < perBar; j++) {
219
+ const idx = i * perBar + j
220
+ if (idx < usable) sum += freq[idx]
221
+ }
222
+ const amplified = (sum / perBar / 255) * 1.5
223
+ const h = Math.max(amplified * (height / 2 - 2), 0.5)
224
+ canvasCtx.fillRect(i * (barWidth + gap), centerY - h, barWidth, h * 2)
225
+ }
226
+ }
227
+
228
+ let lastCanvasWidth = 0
229
+ /** Re-measure the canvas when its container resizes, without churning every frame. */
230
+ function sizeCanvasIfNeeded() {
231
+ if (!canvas || !canvas.parentElement) return
232
+ const w = Math.max(160, Math.min(canvas.parentElement.clientWidth, 800))
233
+ if (w !== lastCanvasWidth) {
234
+ lastCanvasWidth = w
235
+ sizeCanvas()
236
+ }
237
+ }
238
+
239
+ function start() {
240
+ // Parity with the pre-factory guard: start is allowed from idle AND done
241
+ // (re-record without reset); the core guards starting/recording/processing.
242
+ if (disabled || phase === 'starting' || phase === 'recording' || phase === 'processing') return
243
+ updateCanvasColors()
244
+ void rec.start()
245
+ }
246
+
247
+ function pause() {
248
+ rec.pause()
249
+ }
250
+
251
+ function resume() {
252
+ rec.resume()
253
+ }
254
+
255
+ function stop() {
256
+ rec.stop()
257
+ }
258
+
259
+ function cancel() {
260
+ rec.cancel()
261
+ }
262
+
263
+ /** Return a finished/errored recorder to idle so the host can record again. */
264
+ export function reset() {
265
+ rec.reset()
266
+ permissionDenied = false
267
+ }
268
+
269
+ function retryAfterError() {
270
+ reset()
271
+ }
272
+
273
+ onMount(() => {
274
+ updateCanvasColors()
275
+ const unwatch = watchThemeChanges(() => {
276
+ updateCanvasColors()
277
+ clearCanvas()
278
+ })
279
+ const onResize = () => {
280
+ lastCanvasWidth = 0
281
+ if (canvas) sizeCanvas()
282
+ }
283
+ window.addEventListener('resize', onResize)
284
+ return () => {
285
+ window.removeEventListener('resize', onResize)
286
+ unwatch()
287
+ }
288
+ })
289
+
290
+ // While paused the core stops its frame pump — draw the idle dots instead.
291
+ $effect(() => {
292
+ if (phase === 'recording' && paused && canvas) {
293
+ if (!canvasCtx) canvasCtx = canvas.getContext('2d')
294
+ sizeCanvasIfNeeded()
295
+ clearCanvas()
296
+ drawIdleDots()
297
+ }
298
+ })
299
+
300
+ // Space toggles pause/resume while recording (chimaro behaviour).
301
+ function handleKeydown(e: KeyboardEvent) {
302
+ if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
303
+ e.preventDefault()
304
+ if (paused) resume()
305
+ else pause()
306
+ }
307
+ }
308
+
309
+ onDestroy(() => {
310
+ rec.destroy()
311
+ })
312
+ </script>
313
+
314
+ <svelte:document onkeydown={handleKeydown} />
315
+
316
+ <div class={className}>
317
+ {#if variant === 'large'}
318
+ <div class="flex flex-col items-center gap-3">
319
+ {#if phase === 'processing'}
320
+ <div class="badge badge-primary badge-lg gap-2">
321
+ <span class="loading loading-spinner loading-xs"></span>
322
+ Processing…
323
+ </div>
324
+ {:else if phase === 'done'}
325
+ <div class="badge badge-success badge-lg">✓ Complete</div>
326
+ {:else if phase === 'starting'}
327
+ <div class="badge badge-neutral badge-lg gap-2">
328
+ <span class="loading loading-spinner loading-xs"></span>
329
+ Preparing…
330
+ </div>
331
+ {:else if phase === 'recording' && paused}
332
+ <div class="badge badge-warning badge-lg">⏸ Paused</div>
333
+ {:else if phase === 'recording'}
334
+ <div class="badge badge-error badge-lg animate-pulse">● Recording</div>
335
+ {:else}
336
+ <div class="badge badge-neutral badge-lg">Ready to Record</div>
337
+ {/if}
338
+
339
+ {#if phase === 'recording' || phase === 'processing'}
340
+ <div class="font-mono text-5xl font-bold">{formatDuration(elapsedSec)}</div>
341
+ <div class="text-base-content/60 font-mono text-sm">{formatFileSize(sizeBytes)}</div>
342
+ {/if}
343
+
344
+ {#if phase === 'recording'}
345
+ <div class="relative flex min-h-[96px] items-center justify-center">
346
+ {#if !paused}
347
+ <div class="audio-recorder-pulse"></div>
348
+ {/if}
349
+ <canvas bind:this={canvas} class="border-base-300 bg-base-100 z-10 rounded border"></canvas>
350
+ </div>
351
+ <div class="flex min-h-[20px] items-center gap-2">
352
+ <span class="text-base-content/60 text-xs">Volume:</span>
353
+ <div class="bg-base-300 h-1 w-16 overflow-hidden rounded-full">
354
+ <div
355
+ class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
356
+ style="width: {Math.min(audioLevel, 100)}%"
357
+ ></div>
358
+ </div>
359
+ </div>
360
+ <div class="mt-2 flex items-center gap-3">
361
+ {#if paused}
362
+ <button
363
+ type="button"
364
+ class="btn btn-success btn-circle"
365
+ onclick={resume}
366
+ aria-label="Resume recording"
367
+ >
368
+ <MediaCaptureIcon name="play" size={20} class="fill-current" />
369
+ </button>
370
+ {:else}
371
+ <button
372
+ type="button"
373
+ class="btn btn-warning btn-circle"
374
+ onclick={pause}
375
+ aria-label="Pause recording"
376
+ >
377
+ <MediaCaptureIcon name="pause" size={20} />
378
+ </button>
379
+ {/if}
380
+ <button
381
+ type="button"
382
+ class="btn btn-primary btn-circle"
383
+ onclick={stop}
384
+ aria-label="Stop and save recording"
385
+ >
386
+ <MediaCaptureIcon name="square" size={20} class="fill-current" />
387
+ </button>
388
+ </div>
389
+ <button type="button" class="btn btn-ghost btn-sm text-error" onclick={cancel}>
390
+ <MediaCaptureIcon name="x" size={12} />
391
+ Cancel
392
+ </button>
393
+ <p class="text-base-content/45 text-xs">Space pauses / resumes</p>
394
+ {:else if phase === 'idle'}
395
+ <button
396
+ type="button"
397
+ class="btn btn-error btn-circle btn-xl hover:btn-primary transition-all"
398
+ {disabled}
399
+ onclick={start}
400
+ aria-label="Start voice recording"
401
+ >
402
+ <MediaCaptureIcon name="mic" size={24} />
403
+ </button>
404
+ {/if}
405
+
406
+ {#if errorMessage}
407
+ <div class="alert alert-warning w-full max-w-md text-sm" role="status" aria-live="polite">
408
+ <span>{errorMessage}</span>
409
+ {#if phase === 'idle'}
410
+ <button type="button" class="btn btn-xs" onclick={retryAfterError}>Dismiss</button>
411
+ {/if}
412
+ </div>
413
+ {/if}
414
+ {#if permissionDenied}
415
+ <p class="text-base-content/50 max-w-md text-center text-xs">
416
+ Your browser blocked microphone access. Enable it from the lock icon in the address bar
417
+ (or site settings), then press the record button again.
418
+ </p>
419
+ {/if}
420
+ </div>
421
+ {:else}
422
+ <!-- small: chat-inline pill -->
423
+ <div class="flex items-center gap-2">
424
+ {#if phase === 'idle'}
425
+ {#if supported}
426
+ <button
427
+ type="button"
428
+ class="btn btn-ghost btn-circle btn-sm hover:btn-primary transition-all"
429
+ {disabled}
430
+ onclick={start}
431
+ aria-label="Start voice recording"
432
+ title="Start voice recording"
433
+ >
434
+ <MediaCaptureIcon name="mic" size={14} />
435
+ </button>
436
+ {/if}
437
+ {:else if phase === 'starting'}
438
+ <div class="flex items-center gap-2" role="status" aria-live="polite">
439
+ <span class="loading loading-spinner loading-xs"></span>
440
+ <span class="text-base-content/60 text-xs">Preparing…</span>
441
+ </div>
442
+ {:else if phase === 'recording'}
443
+ <div class="bg-base-100 border-base-200 flex items-center gap-2 rounded-lg border p-1.5 shadow-sm">
444
+ {#if paused}
445
+ <button
446
+ type="button"
447
+ class="btn btn-success btn-circle btn-sm"
448
+ onclick={resume}
449
+ aria-label="Resume recording"
450
+ >
451
+ <MediaCaptureIcon name="play" size={14} class="fill-current" />
452
+ </button>
453
+ {:else}
454
+ <button
455
+ type="button"
456
+ class="btn btn-warning btn-circle btn-sm"
457
+ onclick={pause}
458
+ aria-label="Pause recording"
459
+ >
460
+ <MediaCaptureIcon name="pause" size={14} />
461
+ </button>
462
+ {/if}
463
+ <button
464
+ type="button"
465
+ class="btn btn-primary btn-circle btn-sm"
466
+ onclick={stop}
467
+ aria-label="Stop and save recording"
468
+ >
469
+ <MediaCaptureIcon name="square" size={14} class="fill-current" />
470
+ </button>
471
+ <span class="text-base-content/60 w-9 text-center font-mono text-xs">
472
+ {formatDuration(elapsedSec)}
473
+ </span>
474
+ <div class="bg-base-300 h-1 w-8 overflow-hidden rounded-full">
475
+ <div
476
+ class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
477
+ style="width: {Math.min(audioLevel, 100)}%"
478
+ ></div>
479
+ </div>
480
+ <button
481
+ type="button"
482
+ class="btn btn-ghost btn-xs text-error"
483
+ onclick={cancel}
484
+ aria-label="Cancel recording"
485
+ >
486
+ <MediaCaptureIcon name="x" size={10} />
487
+ </button>
488
+ </div>
489
+ {:else if phase === 'processing'}
490
+ <div class="flex items-center gap-2" role="status" aria-live="polite">
491
+ <MediaCaptureIcon name="loader" size={14} class="animate-spin" />
492
+ <span class="text-base-content/60 text-xs">Processing…</span>
493
+ </div>
494
+ {:else if phase === 'done'}
495
+ <span class="text-success text-xs">✓ Saved</span>
496
+ <button type="button" class="btn btn-ghost btn-xs" onclick={reset}>Record again</button>
497
+ {/if}
498
+ {#if errorMessage && variant === 'small'}
499
+ <span class="text-warning text-xs" role="status" aria-live="polite">{errorMessage}</span>
500
+ {/if}
501
+ </div>
502
+ {/if}
503
+ </div>
504
+
505
+ <style>
506
+ .audio-recorder-pulse {
507
+ position: absolute;
508
+ inset: -8px;
509
+ border-radius: 12px;
510
+ background: color-mix(in oklch, var(--color-error) 10%, transparent);
511
+ animation: audio-recorder-pulse 2s ease-in-out infinite;
512
+ }
513
+ @keyframes audio-recorder-pulse {
514
+ 0%,
515
+ 100% {
516
+ transform: scale(1);
517
+ opacity: 0.3;
518
+ }
519
+ 50% {
520
+ transform: scale(1.05);
521
+ opacity: 0.6;
522
+ }
523
+ }
524
+ @media (prefers-reduced-motion: reduce) {
525
+ .audio-recorder-pulse {
526
+ animation: none;
527
+ }
528
+ }
529
+ </style>
@@ -0,0 +1,30 @@
1
+ export type { AudioRecordingResult } from '../utils/audioRecorderCore';
2
+ /** m:ss zero-padded seconds — shared format for the timer display. */
3
+ export declare function formatDuration(totalSeconds: number): string;
4
+ /** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
5
+ export declare function formatFileSize(bytes: number): string;
6
+ interface Props {
7
+ /** `small` — inline chat-style pill (default). `large` — full recorder panel with waveform canvas. */
8
+ variant?: 'small' | 'large';
9
+ /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
10
+ maxDurationMs?: number;
11
+ disabled?: boolean;
12
+ /** Fired once the mic stream is live and recording has started. */
13
+ onRecordStart?: () => void;
14
+ onRecordPause?: () => void;
15
+ onRecordResume?: () => void;
16
+ /** Fired with the finished recording. May return a Promise — the
17
+ * component stays in "processing" until it settles, so hosts can
18
+ * upload/transcribe without the user re-triggering a recording. */
19
+ onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>;
20
+ /** Fired when the user cancels — no blob is produced. */
21
+ onRecordCancel?: () => void;
22
+ /** Fired on recorder errors (permission denied, no mic, start failure). */
23
+ onError?: (message: string) => void;
24
+ class?: string;
25
+ }
26
+ declare const AudioRecorder: import("svelte").Component<Props, {
27
+ reset: () => void;
28
+ }, "">;
29
+ type AudioRecorder = ReturnType<typeof AudioRecorder>;
30
+ export default AudioRecorder;
@@ -14,6 +14,16 @@
14
14
  * shape as flat actions. Flat + grouped can be mixed; flat actions
15
15
  * render first, then grouped dropdowns.
16
16
  *
17
+ * Per-action single-key hotkeys: give a flat action a one-character
18
+ * `key` (e.g. `'c'`) and the component registers a window keydown
19
+ * handler that fires `onSelect` for it while the bar is visible and not
20
+ * loading — ignored while the focus is in a text entry target (shared
21
+ * `isTypingTarget`), for modifier combos and for non-printable keys.
22
+ * The key also renders as a `<kbd>` hint on the button.
23
+ *
24
+ * While `loadingMessage` is set, the buttons are replaced by a spinner +
25
+ * message row (matches the IDE's BulkActionBar fork).
26
+ *
17
27
  * Usage — flat only:
18
28
  *
19
29
  * const nav = listNav({ selectable: true });
@@ -43,12 +53,18 @@
43
53
  */
44
54
 
45
55
  import FloatingActionBar from './FloatingActionBar.svelte';
56
+ import { matchBulkHotkey } from './bulkActionBarKeys';
46
57
  import type { ActionButton, ActionDropdown } from './FloatingActionBar.svelte';
47
58
 
48
59
  export interface BulkAction {
49
60
  label: string;
50
61
  /** Optional SVG path string for a 24×24 icon. */
51
62
  icon?: string;
63
+ /** Optional single-key hotkey (e.g. `'c'`) — fires `onSelect` while the
64
+ * bar is visible and not loading. Ignored while typing (isTypingTarget),
65
+ * for modifier combos and for non-printable keys. Rendered as a <kbd>
66
+ * hint on the button. */
67
+ key?: string;
52
68
  /** Called when the action is clicked. */
53
69
  onSelect: () => void;
54
70
  /** Renders in the danger variant. Default false. */
@@ -85,6 +101,10 @@
85
101
  onClear?: () => void;
86
102
  /** Loading state — disables all actions and shows a spinner. */
87
103
  loading?: boolean;
104
+ /** When set, replaces the action buttons with a spinner + this message
105
+ * (role="status", aria-live="polite") — the "bulk action in flight"
106
+ * presentation from the IDE's fork. */
107
+ loadingMessage?: string | null;
88
108
  /** Label for the count. Default "selected". */
89
109
  countLabel?: string;
90
110
  }
@@ -95,6 +115,7 @@
95
115
  groups = [],
96
116
  onClear,
97
117
  loading = false,
118
+ loadingMessage = null,
98
119
  countLabel = 'selected',
99
120
  }: Props = $props();
100
121
 
@@ -111,11 +132,29 @@
111
132
  id: a.id ?? a.label,
112
133
  label: a.label,
113
134
  icon: a.icon,
135
+ key: a.key,
114
136
  variant: a.variant ?? (a.destructive ? 'danger' : 'default'),
115
137
  onclick: a.onSelect,
116
138
  })),
117
139
  );
118
140
 
141
+ // Single-key hotkeys: fire the matching action's onSelect while the bar
142
+ // is visible and idle. Deciding logic is the pure matchBulkHotkey —
143
+ // typing targets, modifier combos and non-printable keys never fire.
144
+ $effect(() => {
145
+ if (count === 0 || loading || loadingMessage) return;
146
+ const hotkeyed = actions.filter((a) => a.key);
147
+ if (hotkeyed.length === 0) return;
148
+ function onKeydown(event: KeyboardEvent) {
149
+ const action = matchBulkHotkey(event, hotkeyed);
150
+ if (!action) return;
151
+ event.preventDefault();
152
+ action.onSelect();
153
+ }
154
+ window.addEventListener('keydown', onKeydown);
155
+ return () => window.removeEventListener('keydown', onKeydown);
156
+ });
157
+
119
158
  const mappedDropdowns = $derived<ActionDropdown[]>(
120
159
  groups.map((g) => ({
121
160
  id: g.id ?? g.label,
@@ -135,6 +174,7 @@
135
174
  actions={mapped}
136
175
  dropdowns={mappedDropdowns}
137
176
  {loading}
177
+ {loadingMessage}
138
178
  {onClear}
139
179
  {countLabel}
140
180
  />
@@ -3,6 +3,11 @@ export interface BulkAction {
3
3
  label: string;
4
4
  /** Optional SVG path string for a 24×24 icon. */
5
5
  icon?: string;
6
+ /** Optional single-key hotkey (e.g. `'c'`) — fires `onSelect` while the
7
+ * bar is visible and not loading. Ignored while typing (isTypingTarget),
8
+ * for modifier combos and for non-printable keys. Rendered as a <kbd>
9
+ * hint on the button. */
10
+ key?: string;
6
11
  /** Called when the action is clicked. */
7
12
  onSelect: () => void;
8
13
  /** Renders in the danger variant. Default false. */
@@ -37,6 +42,10 @@ interface Props {
37
42
  onClear?: () => void;
38
43
  /** Loading state — disables all actions and shows a spinner. */
39
44
  loading?: boolean;
45
+ /** When set, replaces the action buttons with a spinner + this message
46
+ * (role="status", aria-live="polite") — the "bulk action in flight"
47
+ * presentation from the IDE's fork. */
48
+ loadingMessage?: string | null;
40
49
  /** Label for the count. Default "selected". */
41
50
  countLabel?: string;
42
51
  }