@joewinke/jatui 0.23.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 (34) hide show
  1. package/CLAUDE.md +32 -2
  2. package/dist/components/AudioRecorder.svelte +84 -304
  3. package/dist/components/AudioRecorder.svelte.d.ts +1 -7
  4. package/dist/components/BulkActionBar.svelte +40 -0
  5. package/dist/components/BulkActionBar.svelte.d.ts +9 -0
  6. package/dist/components/ConfirmDialog.svelte +39 -35
  7. package/dist/components/CopyButton.svelte +5 -2
  8. package/dist/components/FloatingActionBar.svelte +55 -1
  9. package/dist/components/FloatingActionBar.svelte.d.ts +8 -1
  10. package/dist/components/InputDialog.svelte +49 -38
  11. package/dist/components/KeyboardShortcutsOverlay.svelte +54 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +30 -0
  13. package/dist/components/bulkActionBarKeys.d.ts +40 -0
  14. package/dist/components/bulkActionBarKeys.js +38 -0
  15. package/dist/components/linked-columns/LinkedColumns.svelte +10 -22
  16. package/dist/components/linked-columns/LinkedColumns.svelte.d.ts +1 -9
  17. package/dist/components/linked-columns/types.d.ts +9 -0
  18. package/dist/components/linked-columns/types.js +1 -0
  19. package/dist/components/replay/ChapterTimeline.svelte +43 -25
  20. package/dist/components/replay/ChapterTimeline.svelte.d.ts +26 -1
  21. package/dist/components/toast/ToastItem.svelte +47 -2
  22. package/dist/index.d.ts +7 -4
  23. package/dist/index.js +5 -1
  24. package/dist/stores/toast.d.ts +8 -0
  25. package/dist/stores/toast.js +2 -0
  26. package/dist/utils/audioRecorder.d.ts +28 -0
  27. package/dist/utils/audioRecorder.js +58 -0
  28. package/dist/utils/audioRecorderCore.d.ts +132 -0
  29. package/dist/utils/audioRecorderCore.js +447 -0
  30. package/dist/utils/clipboard.d.ts +25 -0
  31. package/dist/utils/clipboard.js +59 -0
  32. package/dist/utils/dateFormatters.d.ts +11 -2
  33. package/dist/utils/dateFormatters.js +22 -2
  34. package/package.json +1 -1
package/CLAUDE.md CHANGED
@@ -71,7 +71,7 @@ Then import:
71
71
  | `Tooltip` | Component-shaped wrapper around the shared `use:tooltip` action — for cases where you can't easily add an attribute to the target element (wrap a chip / badge / icon in the tooltip instead). Warm/cool window + `aria-describedby` are shared with the action. Prefer `use:tooltip` directly when you own the element. | `content: string`, `placement?`, `delayMs?`, `disabled?`, `display?`, `children` (snippet) |
72
72
  | `PresenceStack` | "Who's here" avatar stack for live-collaboration UI — max `maxVisible` avatars + "+N more" overflow bubble, tooltip per avatar ("X is editing FIELD" / "X is viewing"). Purely presentational — feed it viewers from any presence source (e.g. an SSE-backed presence composable). | `viewers: PresenceViewer[]`, `maxVisible?` (default 5), `size?`, `class?` |
73
73
  | `DirtyFormHost` | Composable form dirty-state manager — renders your form body plus a floating "Unsaved changes" pill + Cancel button; wires `beforeunload` and (optional) SvelteKit `beforeNavigate`. Callers who want bespoke pill/Cancel chrome use `createDirtyGuard()` directly and skip the component. Extracted from the F12 pattern in `/admin/roles/[id]`. | `baseline: T`, `current: T`, `onCancel?`, `beforeNavigate?`, `discardPrompt?`, `equals?`, `hideChrome?`, `children` (snippet with `{dirty, cancel}`) |
74
- | `BulkActionBar` | Floating action bar that appears when a `listNav` selection is non-empty. Simpler API than `FloatingActionBar`: takes a `Set`, `Array`, or plain number for `selection`; a flat `actions[]` with `{label, onSelect, destructive?}`; and now `groups[]` for dropdown-menu actions (each group is one button on the bar that opens a menu of `{label, onSelect}` items — same shape as `BulkAction`). Flat + grouped can be mixed. Reach for `FloatingActionBar` directly only when you need finer per-item dropdown styling (dot colors, icon snippets). | `selection: Set \| Array \| number`, `actions?: BulkAction[]`, `groups?: BulkActionGroup[]`, `onClear?`, `loading?`, `countLabel?` |
74
+ | `BulkActionBar` | Floating action bar that appears when a `listNav` selection is non-empty. Simpler API than `FloatingActionBar`: takes a `Set`, `Array`, or plain number for `selection`; a flat `actions[]` with `{label, onSelect, destructive?}`; and now `groups[]` for dropdown-menu actions (each group is one button on the bar that opens a menu of `{label, onSelect}` items — same shape as `BulkAction`). Flat + grouped can be mixed. Flat actions take a one-char `key` — the component registers the keydown handler itself (fired only while the bar is visible/idle; ignored while typing via `isTypingTarget`, and for modifier combos), and the key renders as a `<kbd>` hint on the button. A `loadingMessage` replaces the buttons with a spinner + message row while a bulk action is in flight (matches the IDE's fork). Reach for `FloatingActionBar` directly only when you need finer per-item dropdown styling (dot colors, icon snippets). | `selection: Set \| Array \| number`, `actions?: BulkAction[]`, `groups?: BulkActionGroup[]`, `onClear?`, `loading?`, `loadingMessage?`, `countLabel?` |
75
75
  | `BatchJobTable` | Generic queue runner (harvested from vid-gen's BatchGenerator) — `<BatchJobTable generics T>` over bindable `JobRow<T>[]`; host supplies async `run(data, {signal, attempt, reportProgress})`. Concurrency cap, optional per-run `processLimit` (quota budget), rolling-average ETA, per-row status badge / progress / elapsed / retry / skip / cancel / reorder — each cell a snippet override (`statusCell`, `progressCell`, `elapsedCell`, `actionsCell`), host data columns via `headers` + `cells`, extra toolbar content via `toolbar`. Uses `showToast` / `showConfirm` (mount `ToastHost` + `ConfirmDialog`). Exposes `start()`/`pause()`/`cancel()`/`retryFailed()`/`clearFinished()`. Pure scheduling helpers exported too (`nextJobsToStart`, `moveJob`, `summarizeJobs`, `toJobRows`). | `jobs` (bindable), `run`, `concurrencyLimit?` (3), `processLimit?`, `autoStart?`, `notify?`, `confirmDestructive?`, `onJobDone?`, `onJobFailed?`, `onBatchDone?` |
76
76
  | `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
77
77
  | `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
@@ -150,6 +150,29 @@ import { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from 'jatui
150
150
  | `AudioWaveform` | Audio waveform visualizer | `audioUrl`, `isPlaying` |
151
151
  | `VoicePlayer` | Audio playback with controls | `src`, `autoplay` |
152
152
  | `SpeechForm` | Voice-to-text form input | `onTranscript` |
153
+ | `AudioRecorder` | Voice-memo recorder UI (small pill / large panel with canvas waveform) — markup over the headless factory below | `variant`, `maxDurationMs`, `onRecordStart/Pause/Resume/Stop/Cancel`, `onError` |
154
+
155
+ ```typescript
156
+ // Headless audio capture (jatui-is2ow.8) — the factory behind AudioRecorder.
157
+ // Permission requests, MIME negotiation, record/pause/resume/stop/cancel state
158
+ // machine, pause-aware wall-clock timing, AnalyserNode level meter, blob out.
159
+ // NO transcription — hosts keep their own pipeline and consume onStop's blob
160
+ // (the jat IDE's VoiceMicButton/VoiceInput swap uses it this way).
161
+ import { createAudioRecorder } from "jatui"
162
+ const rec = createAudioRecorder({
163
+ mode: "push", // 'toggle' (default) | 'push' (hold-to-talk: pointerdown→start, pointerup→stop;
164
+ // a stop during stream acquisition cancels silently, matching VoiceMicButton)
165
+ maxDurationMs: 0, // auto-stop ceiling; 0 = until stopped
166
+ onStart?, onPause?, onResume?, onCancel?,
167
+ onStop?, // (result: AudioRecordingResult) — blob/mimeType/durationSeconds/sizeBytes;
168
+ // may be async, recorder stays 'processing' until it settles
169
+ onError?, // (err: { kind, message }) — kind: unsupported|denied|no-mic|start-failed|too-short|handler-failed
170
+ onFrame?, // ({ level, frequency, time }) per animation frame — draw your own visualization
171
+ })
172
+ // Reactive state (Svelte 5 getters): rec.phase ('idle'|'starting'|'recording'|'processing'|'done'),
173
+ // rec.paused, rec.level (0–100), rec.elapsedMs, rec.sizeBytes, rec.error, rec.supported
174
+ // Methods: start() stop() pause() resume() cancel() reset() destroy()
175
+ ```
153
176
 
154
177
  ### PDF
155
178
 
@@ -202,7 +225,7 @@ const callbacks: AnnotationCallbacks = {
202
225
 
203
226
  | Component | Purpose | Key Props |
204
227
  |-----------|---------|-----------|
205
- | `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). Precedence: `sections` > `registry` > `shortcuts`. | `shortcuts?: KeyboardShortcut[]`, `sections?: KeyboardShortcutSection[]`, `registry?: ShortcutRegistry`, `title?`, `open` (bindable) |
228
+ | `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). App-specific sections (marks, list motions, cockpit panels) come in via `extraSections` — rendered after the primary content, before the meta rows, in every mode — plus an `extra` snippet for custom footer content. Precedence: `sections` > `registry` > `shortcuts`; `extraSections`/`extra` always append. | `shortcuts?: KeyboardShortcut[]`, `sections?: KeyboardShortcutSection[]`, `registry?: ShortcutRegistry`, `extraSections?: KeyboardShortcutSection[]`, `extra?: Snippet`, `title?`, `open` (bindable) |
206
229
 
207
230
  **Shortcut registry** (`createShortcutRegistry()`, or the lazy `getShortcutRegistry()` singleton) — the data source for the overlay, so apps stop hand-wiring a keydown handler plus a separate list for the overlay. Shortcuts register against a **context** and return an unregister fn (return it from `$effect`). Contexts form a stack: `global` is the bottom, `pushContext(name, { modal?, label? })` returns a pop fn, the top context shadows the same combo lower down, and a `modal` context suppresses everything beneath it (including `global`). Handled for you: no firing while typing in text inputs/textarea/select/contenteditable (opt in per shortcut with `allowInInput`), IME composition and `defaultPrevented` events are skipped, `mod` = ⌘ on macOS / Ctrl elsewhere, exact modifier matching (`?` doesn't need `shift`), and macOS Option combos fall back to `event.code`. Per shortcut: `enabled()`, `allowRepeat`, `preventDefault`, `group` (overlay section), `hidden`, `label` (keycap override).
208
231
 
@@ -315,6 +338,13 @@ import { statusColor, priorityColor, typeIcon, statusLabel } from "jatui"
315
338
  // Phone formatting
316
339
  import { formatPhoneNumber, isValidPhoneNumber } from "jatui"
317
340
 
341
+ // Clipboard — secure-context aware: falls back to hidden-textarea +
342
+ // document.execCommand('copy') on plain-HTTP origins where navigator.clipboard
343
+ // is undefined (fixes "copy button silently does nothing" over Tailscale/LAN).
344
+ // copyText resolves false when both paths fail; readClipboardText resolves
345
+ // null when the read API is missing or denied.
346
+ import { copyText, readClipboardText } from "jatui"
347
+
318
348
  // Currency
319
349
  import { formatCurrency } from "jatui"
320
350
 
@@ -1,11 +1,8 @@
1
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
- }
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'
9
6
 
10
7
  /** m:ss zero-padded seconds — shared format for the timer display. */
11
8
  export function formatDuration(totalSeconds: number): string {
@@ -41,19 +38,15 @@
41
38
  * post-recording transcription or analysis consume `onRecordStop`'s
42
39
  * blob payload and run their own pipeline (MardukOS Scribe does).
43
40
  *
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.
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.
55
47
  */
56
48
  import { onDestroy, onMount } from 'svelte'
49
+ import { createAudioRecorder } from '../utils/audioRecorder'
57
50
  import { getDaisyUIColor, watchThemeChanges } from '../utils/daisyuiColors'
58
51
  import MediaCaptureIcon from './MediaCaptureIcon.svelte'
59
52
 
@@ -91,42 +84,49 @@
91
84
  class: className = ''
92
85
  }: Props = $props()
93
86
 
94
- type Phase = 'idle' | 'recording' | 'processing' | 'done'
95
- type PauseState = 'active' | 'paused'
87
+ type Phase = 'idle' | 'starting' | 'recording' | 'processing' | 'done'
96
88
 
97
- let phase = $state<Phase>('idle')
98
- let paused = $state<PauseState>('active')
99
89
  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
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)
119
124
 
120
125
  // Canvas
121
126
  let canvas = $state<HTMLCanvasElement | null>(null)
122
127
  let canvasCtx: CanvasRenderingContext2D | null = null
123
128
  let canvasColors = { background: '', centerLine: '', primary: '', secondary: '', dots: '' }
124
129
 
125
- const hasUserMedia =
126
- typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
127
- const hasRecorder = typeof MediaRecorder !== 'undefined'
128
- const supported = hasRecorder && hasUserMedia
129
-
130
130
  function resolveColor(name: string, fallback: string): string {
131
131
  if (typeof window === 'undefined') return fallback
132
132
  try {
@@ -147,79 +147,6 @@
147
147
  }
148
148
  }
149
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
150
  function sizeCanvas() {
224
151
  if (!canvas) return
225
152
  const parent = canvas.parentElement
@@ -298,39 +225,6 @@
298
225
  }
299
226
  }
300
227
 
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
228
  let lastCanvasWidth = 0
335
229
  /** Re-measure the canvas when its container resizes, without churning every frame. */
336
230
  function sizeCanvasIfNeeded() {
@@ -342,172 +236,38 @@
342
236
  }
343
237
  }
344
238
 
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
- }
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()
418
245
  }
419
246
 
420
247
  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
- }
248
+ rec.pause()
431
249
  }
432
250
 
433
251
  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
- }
252
+ rec.resume()
446
253
  }
447
254
 
448
255
  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
- }
256
+ rec.stop()
460
257
  }
461
258
 
462
259
  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
- }
260
+ rec.cancel()
497
261
  }
498
262
 
499
263
  /** Return a finished/errored recorder to idle so the host can record again. */
500
264
  export function reset() {
501
- cleanup()
502
- phase = 'idle'
503
- errorMessage = ''
265
+ rec.reset()
504
266
  permissionDenied = false
505
267
  }
506
268
 
507
269
  function retryAfterError() {
508
- errorMessage = ''
509
- permissionDenied = false
510
- phase = 'idle'
270
+ reset()
511
271
  }
512
272
 
513
273
  onMount(() => {
@@ -527,17 +287,27 @@
527
287
  }
528
288
  })
529
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
+
530
300
  // Space toggles pause/resume while recording (chimaro behaviour).
531
301
  function handleKeydown(e: KeyboardEvent) {
532
302
  if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
533
303
  e.preventDefault()
534
- if (paused === 'paused') resume()
304
+ if (paused) resume()
535
305
  else pause()
536
306
  }
537
307
  }
538
308
 
539
309
  onDestroy(() => {
540
- cleanup()
310
+ rec.destroy()
541
311
  })
542
312
  </script>
543
313
 
@@ -553,7 +323,12 @@
553
323
  </div>
554
324
  {:else if phase === 'done'}
555
325
  <div class="badge badge-success badge-lg">✓ Complete</div>
556
- {:else if phase === 'recording' && paused === 'paused'}
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}
557
332
  <div class="badge badge-warning badge-lg">⏸ Paused</div>
558
333
  {:else if phase === 'recording'}
559
334
  <div class="badge badge-error badge-lg animate-pulse">● Recording</div>
@@ -568,7 +343,7 @@
568
343
 
569
344
  {#if phase === 'recording'}
570
345
  <div class="relative flex min-h-[96px] items-center justify-center">
571
- {#if paused !== 'paused'}
346
+ {#if !paused}
572
347
  <div class="audio-recorder-pulse"></div>
573
348
  {/if}
574
349
  <canvas bind:this={canvas} class="border-base-300 bg-base-100 z-10 rounded border"></canvas>
@@ -583,7 +358,7 @@
583
358
  </div>
584
359
  </div>
585
360
  <div class="mt-2 flex items-center gap-3">
586
- {#if paused === 'paused'}
361
+ {#if paused}
587
362
  <button
588
363
  type="button"
589
364
  class="btn btn-success btn-circle"
@@ -659,9 +434,14 @@
659
434
  <MediaCaptureIcon name="mic" size={14} />
660
435
  </button>
661
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>
662
442
  {:else if phase === 'recording'}
663
443
  <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'}
444
+ {#if paused}
665
445
  <button
666
446
  type="button"
667
447
  class="btn btn-success btn-circle btn-sm"
@@ -1,10 +1,4 @@
1
- /** What a finished recording hands back to the host — mirrors MediaCaptureResult's shape for the audio kind. */
2
- export interface AudioRecordingResult {
3
- blob: Blob;
4
- mimeType: string;
5
- durationSeconds: number;
6
- sizeBytes: number;
7
- }
1
+ export type { AudioRecordingResult } from '../utils/audioRecorderCore';
8
2
  /** m:ss zero-padded seconds — shared format for the timer display. */
9
3
  export declare function formatDuration(totalSeconds: number): string;
10
4
  /** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
@@ -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
  />