@joewinke/jatui 0.5.3 → 0.6.1

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.
package/CLAUDE.md CHANGED
@@ -79,6 +79,8 @@ Then import:
79
79
  | `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
80
80
  | `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
81
81
  | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker) and talk-time legend. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and keyboard map, and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek) and zoom over the band. | `src`, `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
82
+ | `MediaCapture` | One-tap audio/selfie-video/screen(+mic) recording — a host-agnostic PRIMITIVE. Owns permission requests, MIME negotiation, the record/stop state machine, live self-preview, and mixes screen+mic audio into one track via AudioContext. Never uploads anything — hands the finished clip to `onCapture` as a Blob and the host decides what to do with it. `onCapture` may be async; the component shows "processing" until it resolves. | `onCapture: (result: MediaCaptureResult) => void \| Promise<void>`, `modes?` (default all three), `maxDurationMs?` (default 120000), `label?`, `hint?`, `compact?`, `disabled?` |
83
+ | `MultimodalCaptureField` | "Upload a file, or record yourself going over it" as one input surface — composes `FileDropzone` (capped to one file) with `MediaCapture`. File and recording are independent (either, both, or neither); neither half uploads anything, same "validate and hand back" contract as `FileDropzone`. | `onFileSelected?`, `onFileRemoved?`, `onCapture?`, `onRecordingRemoved?`, `accept?`, `maxFileSize?`, `modes?`, `maxDurationMs?`, `file?`/`fileUrl?`/`fileName?`, `recordingSummary?` |
82
84
 
83
85
  **`VideoPlayer` vs `TranscriptPlayer`:** VideoPlayer is the generalization — it does everything TranscriptPlayer does plus the timeline band, and its props are structurally compatible with TranscriptPlayer's (`TranscriptSegment[]`, `TranscriptChapter[]`, `TranscriptBookmark[]`, `TranscriptSpeakerTalkTime[]` all pass straight through). `TranscriptPlayer` stays exported and unchanged for the existing meeting-recordings viewer; use `VideoPlayer` for anything new.
84
86
 
@@ -289,6 +291,11 @@ import { DAYS_OF_WEEK, DAYS_SHORT, LOCATION_LABELS, LOCATION_ICONS } from "jatui
289
291
  import type { FileDropzoneFile } from "jatui"
290
292
  // FileDropzoneFile — { file?: File, name, mimeType, size?, url?, meta? }
291
293
 
294
+ // MediaCapture / MultimodalCaptureField types
295
+ import type { MediaCaptureKind, MediaCaptureResult } from "jatui"
296
+ // MediaCaptureKind — 'audio' | 'video' | 'screen'
297
+ // MediaCaptureResult — { kind, blob: Blob, mimeType, durationSeconds }
298
+
292
299
  // SmartImageEditor types
293
300
  import type {
294
301
  ImageProcessingSpec, ImageVariantSpec,
@@ -0,0 +1,491 @@
1
+ <script module lang="ts">
2
+ export type MediaCaptureKind = 'audio' | 'video' | 'screen'
3
+
4
+ export interface MediaCaptureResult {
5
+ kind: MediaCaptureKind
6
+ blob: Blob
7
+ mimeType: string
8
+ durationSeconds: number
9
+ }
10
+ </script>
11
+
12
+ <script lang="ts">
13
+ /**
14
+ * MediaCapture — one-tap audio/selfie-video/screen(+mic) recording.
15
+ *
16
+ * A host-agnostic recording PRIMITIVE: it owns permission requests, MIME
17
+ * negotiation, the record/stop state machine, the live self-preview for
18
+ * video, and mixing screen + mic audio into a single track for `screen`
19
+ * captures. It does NOT know about uploads, transcription, or storage —
20
+ * every finished clip is handed to `onCapture` as a plain Blob, and the
21
+ * host app decides what to do with it (upload endpoint, local preview,
22
+ * whatever). `onCapture` may be async; the component shows a "processing"
23
+ * state while it resolves so the host can do slow work (upload/transcribe)
24
+ * without the user re-triggering a second recording mid-flight.
25
+ *
26
+ * Screen capture mixes the display's own audio (when the browser/source
27
+ * offers it) with the microphone via a single AudioContext destination —
28
+ * MediaRecorder support for multiple simultaneous audio tracks on one
29
+ * MediaStream is inconsistent across browsers, so mixing to one track is
30
+ * the portable choice for "narrate over a shared tab."
31
+ *
32
+ * Purely additive: renders nothing when neither getUserMedia nor
33
+ * getDisplayMedia exists, and never throws — a permission denial or
34
+ * cancelled screen-share surfaces as inline `errorMessage`, and the
35
+ * component returns to idle so the caller can retry.
36
+ *
37
+ * Two UAT findings shaped the recording UI (mos-ow4q4.37):
38
+ * 1. The elapsed clock is driven by the recorder's own `ondataavailable`
39
+ * timeslice, not only by a setInterval. A `screen` capture is normally
40
+ * started against another window, which leaves THIS page hidden — and
41
+ * a hidden page's timers are throttled to roughly one tick a minute,
42
+ * so the interval alone left the timer visibly frozen mid-recording.
43
+ * 2. A fixed-position banner announces an active screen capture in the
44
+ * page itself. The browser's sharing indicator is chrome, not content:
45
+ * it is not visible from the shared window and it never says how long
46
+ * THIS recording has run.
47
+ */
48
+ import MediaCaptureIcon from './MediaCaptureIcon.svelte'
49
+
50
+ interface Props {
51
+ /** Which capture modes to offer. Default: all three. */
52
+ modes?: MediaCaptureKind[]
53
+ /** Auto-stop ceiling in ms. Default 2 minutes — "quick capture", not a full recording studio. */
54
+ maxDurationMs?: number
55
+ /** Called with the finished clip. May return a Promise — the component
56
+ * stays in "processing" until it settles. */
57
+ onCapture: (result: MediaCaptureResult) => void | Promise<void>
58
+ /** Prompt text above the mode buttons. */
59
+ label?: string
60
+ hint?: string
61
+ disabled?: boolean
62
+ /** Smaller footprint (icon-only mode buttons) for embedding inside a
63
+ * larger composite field like MultimodalCaptureField. */
64
+ compact?: boolean
65
+ /** Override the per-mode button label. Hosts that frame recording as
66
+ * "add another response" want their own wording ("Voice" vs "Add voice"),
67
+ * and the screen mode in particular has been called both "Screen" and
68
+ * "Show us" in different products. */
69
+ modeLabels?: Partial<Record<MediaCaptureKind, string>>
70
+ /** Prefix each mode button with a `+`, framing the modes as
71
+ * add-affordances on a field that accepts SEVERAL responses rather than
72
+ * as a one-shot record button. */
73
+ addAffordance?: boolean
74
+ /** Render a fixed, page-level "screen sharing is active" banner while a
75
+ * `screen` capture runs. The browser's own sharing chrome lives outside
76
+ * the page, so without this the page itself gives no sign it is being
77
+ * recorded — the single most-reported confusion in UAT. */
78
+ screenIndicator?: boolean
79
+ /** Observe the capture state machine (phase / kind / elapsed). Lets a
80
+ * host disable competing controls or mirror the recording state without
81
+ * reaching into this component's internals. */
82
+ onStateChange?: (state: {
83
+ phase: 'idle' | 'recording' | 'processing' | 'error'
84
+ kind: MediaCaptureKind | null
85
+ elapsedSeconds: number
86
+ }) => void
87
+ class?: string
88
+ }
89
+
90
+ let {
91
+ modes = ['audio', 'video', 'screen'],
92
+ maxDurationMs = 120_000,
93
+ onCapture,
94
+ label = 'Record a note',
95
+ hint,
96
+ disabled = false,
97
+ compact = false,
98
+ modeLabels,
99
+ addAffordance = false,
100
+ screenIndicator = true,
101
+ onStateChange,
102
+ class: className = ''
103
+ }: Props = $props()
104
+
105
+ type Phase = 'idle' | 'recording' | 'processing' | 'error'
106
+ let phase = $state<Phase>('idle')
107
+ let activeKind = $state<MediaCaptureKind | null>(null)
108
+ let errorMessage = $state('')
109
+ let elapsedSec = $state(0)
110
+
111
+ $effect(() => {
112
+ onStateChange?.({ phase, kind: activeKind, elapsedSeconds: elapsedSec })
113
+ })
114
+
115
+ let recorder: MediaRecorder | null = null
116
+ let primaryStream: MediaStream | null = null
117
+ let secondaryStream: MediaStream | null = null
118
+ let audioCtx: AudioContext | null = null
119
+ let recordedStream: MediaStream | null = null
120
+ let chunks: Blob[] = []
121
+ let mimeUsed = ''
122
+ let startedAt = 0
123
+ let autoStopTimer: ReturnType<typeof setTimeout> | null = null
124
+ let tickTimer: ReturnType<typeof setInterval> | null = null
125
+
126
+ let videoPreviewEl = $state<HTMLVideoElement | null>(null)
127
+
128
+ const hasUserMedia =
129
+ typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
130
+ const hasDisplayMedia =
131
+ typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getDisplayMedia
132
+ const hasRecorder = typeof MediaRecorder !== 'undefined'
133
+ const supported = hasRecorder && (hasUserMedia || hasDisplayMedia)
134
+
135
+ const showAudio = $derived(modes.includes('audio') && hasUserMedia)
136
+ const showVideo = $derived(modes.includes('video') && hasUserMedia)
137
+ const showScreen = $derived(modes.includes('screen') && hasDisplayMedia)
138
+ const offeredModes = $derived(
139
+ (['audio', 'video', 'screen'] as MediaCaptureKind[]).filter((k) =>
140
+ k === 'audio' ? showAudio : k === 'video' ? showVideo : showScreen
141
+ )
142
+ )
143
+
144
+ function pickMime(candidates: string[]): string {
145
+ for (const c of candidates) {
146
+ if (MediaRecorder.isTypeSupported(c)) return c
147
+ }
148
+ return ''
149
+ }
150
+
151
+ function stopAllTracks(stream: MediaStream | null) {
152
+ stream?.getTracks().forEach((t) => t.stop())
153
+ }
154
+
155
+ /** Recompute elapsed from the wall clock rather than incrementing a counter,
156
+ * so a tick that never fired (throttled tab) can never make the display
157
+ * lag the actual recording. */
158
+ function tick() {
159
+ if (!startedAt) return
160
+ elapsedSec = Math.floor((performance.now() - startedAt) / 1000)
161
+ }
162
+
163
+ function cleanup() {
164
+ stopAllTracks(primaryStream)
165
+ stopAllTracks(secondaryStream)
166
+ stopAllTracks(recordedStream)
167
+ primaryStream = null
168
+ secondaryStream = null
169
+ recordedStream = null
170
+ if (audioCtx) {
171
+ audioCtx.close().catch(() => {})
172
+ audioCtx = null
173
+ }
174
+ recorder = null
175
+ chunks = []
176
+ if (autoStopTimer) {
177
+ clearTimeout(autoStopTimer)
178
+ autoStopTimer = null
179
+ }
180
+ if (tickTimer) {
181
+ clearInterval(tickTimer)
182
+ tickTimer = null
183
+ }
184
+ if (typeof document !== 'undefined') {
185
+ document.removeEventListener('visibilitychange', tick)
186
+ }
187
+ startedAt = 0
188
+ if (videoPreviewEl) videoPreviewEl.srcObject = null
189
+ }
190
+
191
+ async function start(kind: MediaCaptureKind) {
192
+ if (!supported || disabled || phase === 'recording' || phase === 'processing') return
193
+ errorMessage = ''
194
+ try {
195
+ let stream: MediaStream
196
+ if (kind === 'audio') {
197
+ primaryStream = await navigator.mediaDevices.getUserMedia({ audio: true })
198
+ stream = primaryStream
199
+ mimeUsed = pickMime([
200
+ 'audio/webm;codecs=opus',
201
+ 'audio/webm',
202
+ 'audio/mp4',
203
+ 'audio/ogg'
204
+ ])
205
+ } else if (kind === 'video') {
206
+ primaryStream = await navigator.mediaDevices.getUserMedia({
207
+ audio: true,
208
+ video: { facingMode: 'user' }
209
+ })
210
+ stream = primaryStream
211
+ mimeUsed = pickMime([
212
+ 'video/webm;codecs=vp9,opus',
213
+ 'video/webm;codecs=vp8,opus',
214
+ 'video/webm',
215
+ 'video/mp4'
216
+ ])
217
+ } else {
218
+ primaryStream = await navigator.mediaDevices.getDisplayMedia({
219
+ video: true,
220
+ audio: true
221
+ })
222
+ try {
223
+ secondaryStream = await navigator.mediaDevices.getUserMedia({ audio: true })
224
+ } catch {
225
+ secondaryStream = null
226
+ }
227
+ const AudioCtxCls =
228
+ window.AudioContext ||
229
+ (window as unknown as { webkitAudioContext: typeof AudioContext })
230
+ .webkitAudioContext
231
+ audioCtx = new AudioCtxCls()
232
+ const dest = audioCtx.createMediaStreamDestination()
233
+ for (const s of [primaryStream, secondaryStream]) {
234
+ const tracks = s?.getAudioTracks() ?? []
235
+ if (tracks.length) {
236
+ audioCtx.createMediaStreamSource(new MediaStream(tracks)).connect(dest)
237
+ }
238
+ }
239
+ stream = new MediaStream([
240
+ ...primaryStream.getVideoTracks(),
241
+ ...dest.stream.getAudioTracks()
242
+ ])
243
+ primaryStream.getVideoTracks()[0]?.addEventListener('ended', () => {
244
+ if (phase === 'recording') stop()
245
+ })
246
+ mimeUsed = pickMime([
247
+ 'video/webm;codecs=vp9,opus',
248
+ 'video/webm;codecs=vp8,opus',
249
+ 'video/webm'
250
+ ])
251
+ }
252
+ recordedStream = stream
253
+
254
+ if (kind === 'video' && videoPreviewEl) {
255
+ videoPreviewEl.srcObject = stream
256
+ void videoPreviewEl.play().catch(() => {})
257
+ }
258
+
259
+ chunks = []
260
+ const rec = new MediaRecorder(stream, mimeUsed ? { mimeType: mimeUsed } : undefined)
261
+ rec.ondataavailable = (e) => {
262
+ if (e.data.size > 0) chunks.push(e.data)
263
+ // The authoritative tick. `screen` captures are usually started
264
+ // against ANOTHER window or tab, which leaves this page hidden —
265
+ // and a hidden page's setInterval is throttled to roughly once a
266
+ // minute, which is exactly why the UAT screen timer sat frozen at
267
+ // 0:12. MediaRecorder keeps firing this at the timeslice interval
268
+ // regardless of page visibility, so the clock stays live.
269
+ tick()
270
+ }
271
+ rec.onstop = () => void finish(kind)
272
+ rec.start(250)
273
+ recorder = rec
274
+ startedAt = performance.now()
275
+ elapsedSec = 0
276
+ activeKind = kind
277
+ phase = 'recording'
278
+ // Belt: the interval covers the idle gap between timeslices while the
279
+ // page is visible. Braces: a visibility flip resyncs immediately, so
280
+ // returning to a backgrounded tab never shows a stale number.
281
+ tickTimer = setInterval(tick, 500)
282
+ if (typeof document !== 'undefined') {
283
+ document.addEventListener('visibilitychange', tick)
284
+ }
285
+ autoStopTimer = setTimeout(stop, maxDurationMs)
286
+ } catch {
287
+ cleanup()
288
+ phase = 'error'
289
+ activeKind = null
290
+ errorMessage =
291
+ kind === 'screen'
292
+ ? 'Screen sharing was cancelled or is not available.'
293
+ : 'Camera/microphone permission is needed to record.'
294
+ }
295
+ }
296
+
297
+ function stop() {
298
+ if (recorder && recorder.state !== 'inactive') {
299
+ recorder.stop() // → onstop → finish()
300
+ }
301
+ }
302
+
303
+ async function finish(kind: MediaCaptureKind) {
304
+ const durationSeconds = (performance.now() - startedAt) / 1000
305
+ const blob = new Blob(chunks, { type: mimeUsed || 'video/webm' })
306
+ cleanup()
307
+
308
+ if (blob.size === 0) {
309
+ phase = 'idle'
310
+ activeKind = null
311
+ return
312
+ }
313
+
314
+ phase = 'processing'
315
+ try {
316
+ await onCapture({ kind, blob, mimeType: mimeUsed, durationSeconds })
317
+ phase = 'idle'
318
+ activeKind = null
319
+ } catch {
320
+ phase = 'error'
321
+ errorMessage = "Couldn't save that recording — please try again."
322
+ }
323
+ }
324
+
325
+ function modeIcon(kind: MediaCaptureKind): 'mic' | 'video' | 'monitor' {
326
+ return kind === 'audio' ? 'mic' : kind === 'video' ? 'video' : 'monitor'
327
+ }
328
+
329
+ const DEFAULT_MODE_LABELS: Record<MediaCaptureKind, string> = {
330
+ audio: 'Voice',
331
+ video: 'Video',
332
+ // "Screen" says what it captures. "Show us" said what to do with it and
333
+ // left people guessing (mos-ow4q4.37); hosts that preferred the old
334
+ // wording can pass it back through `modeLabels`.
335
+ screen: 'Screen'
336
+ }
337
+
338
+ function modeLabel(kind: MediaCaptureKind): string {
339
+ return modeLabels?.[kind] ?? DEFAULT_MODE_LABELS[kind]
340
+ }
341
+
342
+ function formatElapsed(sec: number): string {
343
+ const m = Math.floor(sec / 60)
344
+ const s = sec % 60
345
+ return `${m}:${String(s).padStart(2, '0')}`
346
+ }
347
+ </script>
348
+
349
+ {#if supported}
350
+ <div class="media-capture {className}">
351
+ {#if !compact && (label || hint)}
352
+ <div class="mb-2">
353
+ {#if label}<p class="text-base-content text-sm font-medium">{label}</p>{/if}
354
+ {#if hint}<p class="text-base-content/60 text-xs">{hint}</p>{/if}
355
+ </div>
356
+ {/if}
357
+
358
+ {#if phase === 'recording'}
359
+ <div class="flex items-center gap-3">
360
+ <span class="media-capture-dot"></span>
361
+ <span class="text-base-content/70 text-xs tabular-nums">
362
+ {modeLabel(activeKind as MediaCaptureKind)} — {formatElapsed(elapsedSec)}
363
+ </span>
364
+ <button type="button" class="btn btn-error btn-xs rounded-field gap-1" onclick={stop}>
365
+ <MediaCaptureIcon name="square" size={12} /> Stop
366
+ </button>
367
+ </div>
368
+ {#if activeKind === 'video'}
369
+ <video
370
+ bind:this={videoPreviewEl}
371
+ class="border-base-300 mt-2 h-24 w-24 rounded-field border object-cover"
372
+ muted
373
+ playsinline
374
+ >
375
+ <track kind="captions" />
376
+ </video>
377
+ {/if}
378
+ {:else if phase === 'processing'}
379
+ <div class="flex items-center gap-2">
380
+ <MediaCaptureIcon name="loader" size={14} class="animate-spin" />
381
+ <span class="text-base-content/70 text-xs">Saving…</span>
382
+ </div>
383
+ {:else}
384
+ <div class="flex flex-wrap gap-2">
385
+ {#each offeredModes as kind (kind)}
386
+ <button
387
+ type="button"
388
+ class="btn btn-outline btn-xs rounded-field gap-1"
389
+ {disabled}
390
+ onclick={() => start(kind)}
391
+ >
392
+ {#if addAffordance}
393
+ <MediaCaptureIcon name="plus" size={12} />
394
+ {/if}
395
+ <MediaCaptureIcon name={modeIcon(kind)} size={12} />
396
+ {modeLabel(kind)}
397
+ </button>
398
+ {/each}
399
+ </div>
400
+ {#if phase === 'error' && errorMessage}
401
+ <p class="text-warning mt-1.5 text-xs" role="status" aria-live="polite">
402
+ {errorMessage}
403
+ </p>
404
+ {/if}
405
+ {/if}
406
+ </div>
407
+
408
+ <!--
409
+ In-page proof that the screen is being captured. The browser's own
410
+ "sharing your screen" bar is browser chrome, not page content — it is
411
+ absent when the user is looking at the shared window, and it never says
412
+ how long this particular recording has been running. Fixed-position so it
413
+ survives scrolling, and it carries its own Stop so the user never has to
414
+ hunt back up the page for the control.
415
+ -->
416
+ {#if screenIndicator && phase === 'recording' && activeKind === 'screen'}
417
+ <div class="media-capture-screen-banner" role="status" aria-live="polite">
418
+ <span class="media-capture-dot"></span>
419
+ <MediaCaptureIcon name="monitor" size={14} />
420
+ <span class="text-xs font-medium">
421
+ Recording your screen — {formatElapsed(elapsedSec)}
422
+ </span>
423
+ <button type="button" class="btn btn-error btn-xs rounded-field gap-1" onclick={stop}>
424
+ <MediaCaptureIcon name="square" size={12} /> Stop
425
+ </button>
426
+ </div>
427
+ {/if}
428
+ {/if}
429
+
430
+ <style>
431
+ .media-capture-screen-banner {
432
+ position: fixed;
433
+ top: 0.75rem;
434
+ left: 50%;
435
+ transform: translateX(-50%);
436
+ z-index: 9999;
437
+ display: flex;
438
+ align-items: center;
439
+ gap: 0.5rem;
440
+ padding: 0.4rem 0.75rem;
441
+ border-radius: var(--radius-box, 0.5rem);
442
+ border: 1px solid color-mix(in oklch, var(--color-error) 45%, transparent);
443
+ background: var(--color-base-100);
444
+ color: var(--color-base-content);
445
+ box-shadow: 0 4px 16px color-mix(in oklch, var(--color-base-content) 18%, transparent);
446
+ max-width: calc(100vw - 1.5rem);
447
+ }
448
+
449
+ .media-capture-dot {
450
+ display: inline-block;
451
+ width: 0.5rem;
452
+ height: 0.5rem;
453
+ border-radius: 9999px;
454
+ background: var(--color-error);
455
+ animation: media-capture-pulse 1.4s ease-out infinite;
456
+ }
457
+ @keyframes media-capture-pulse {
458
+ 0% {
459
+ box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-error) 55%, transparent);
460
+ }
461
+ 70% {
462
+ box-shadow: 0 0 0 8px color-mix(in oklch, var(--color-error) 0%, transparent);
463
+ }
464
+ 100% {
465
+ box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-error) 0%, transparent);
466
+ }
467
+ }
468
+ @media (prefers-reduced-motion: reduce) {
469
+ .media-capture-screen-banner {
470
+ position: fixed;
471
+ top: 0.75rem;
472
+ left: 50%;
473
+ transform: translateX(-50%);
474
+ z-index: 9999;
475
+ display: flex;
476
+ align-items: center;
477
+ gap: 0.5rem;
478
+ padding: 0.4rem 0.75rem;
479
+ border-radius: var(--radius-box, 0.5rem);
480
+ border: 1px solid color-mix(in oklch, var(--color-error) 45%, transparent);
481
+ background: var(--color-base-100);
482
+ color: var(--color-base-content);
483
+ box-shadow: 0 4px 16px color-mix(in oklch, var(--color-base-content) 18%, transparent);
484
+ max-width: calc(100vw - 1.5rem);
485
+ }
486
+
487
+ .media-capture-dot {
488
+ animation: none;
489
+ }
490
+ }
491
+ </style>
@@ -0,0 +1,49 @@
1
+ export type MediaCaptureKind = 'audio' | 'video' | 'screen';
2
+ export interface MediaCaptureResult {
3
+ kind: MediaCaptureKind;
4
+ blob: Blob;
5
+ mimeType: string;
6
+ durationSeconds: number;
7
+ }
8
+ interface Props {
9
+ /** Which capture modes to offer. Default: all three. */
10
+ modes?: MediaCaptureKind[];
11
+ /** Auto-stop ceiling in ms. Default 2 minutes — "quick capture", not a full recording studio. */
12
+ maxDurationMs?: number;
13
+ /** Called with the finished clip. May return a Promise — the component
14
+ * stays in "processing" until it settles. */
15
+ onCapture: (result: MediaCaptureResult) => void | Promise<void>;
16
+ /** Prompt text above the mode buttons. */
17
+ label?: string;
18
+ hint?: string;
19
+ disabled?: boolean;
20
+ /** Smaller footprint (icon-only mode buttons) for embedding inside a
21
+ * larger composite field like MultimodalCaptureField. */
22
+ compact?: boolean;
23
+ /** Override the per-mode button label. Hosts that frame recording as
24
+ * "add another response" want their own wording ("Voice" vs "Add voice"),
25
+ * and the screen mode in particular has been called both "Screen" and
26
+ * "Show us" in different products. */
27
+ modeLabels?: Partial<Record<MediaCaptureKind, string>>;
28
+ /** Prefix each mode button with a `+`, framing the modes as
29
+ * add-affordances on a field that accepts SEVERAL responses rather than
30
+ * as a one-shot record button. */
31
+ addAffordance?: boolean;
32
+ /** Render a fixed, page-level "screen sharing is active" banner while a
33
+ * `screen` capture runs. The browser's own sharing chrome lives outside
34
+ * the page, so without this the page itself gives no sign it is being
35
+ * recorded — the single most-reported confusion in UAT. */
36
+ screenIndicator?: boolean;
37
+ /** Observe the capture state machine (phase / kind / elapsed). Lets a
38
+ * host disable competing controls or mirror the recording state without
39
+ * reaching into this component's internals. */
40
+ onStateChange?: (state: {
41
+ phase: 'idle' | 'recording' | 'processing' | 'error';
42
+ kind: MediaCaptureKind | null;
43
+ elapsedSeconds: number;
44
+ }) => void;
45
+ class?: string;
46
+ }
47
+ declare const MediaCapture: import("svelte").Component<Props, {}, "">;
48
+ type MediaCapture = ReturnType<typeof MediaCapture>;
49
+ export default MediaCapture;
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Tiny internal icon helper for MediaCapture / MultimodalCaptureField —
4
+ * NOT exported from the package barrel. Inline lucide-style stroke paths,
5
+ * same house convention as Button.svelte/FileDropzone.svelte (no
6
+ * lucide-svelte dependency in this package).
7
+ */
8
+ let {
9
+ name,
10
+ size = 14,
11
+ class: className = ''
12
+ }: {
13
+ name:
14
+ | 'mic'
15
+ | 'video'
16
+ | 'monitor'
17
+ | 'square'
18
+ | 'loader'
19
+ | 'x'
20
+ | 'file'
21
+ | 'trash'
22
+ | 'plus'
23
+ | 'type'
24
+ size?: number
25
+ class?: string
26
+ } = $props()
27
+ </script>
28
+
29
+ {#if name === 'mic'}
30
+ <svg
31
+ width={size}
32
+ height={size}
33
+ viewBox="0 0 24 24"
34
+ fill="none"
35
+ stroke="currentColor"
36
+ stroke-width="2"
37
+ stroke-linecap="round"
38
+ stroke-linejoin="round"
39
+ class={className}
40
+ >
41
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
42
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
43
+ <line x1="12" x2="12" y1="19" y2="22" />
44
+ </svg>
45
+ {:else if name === 'video'}
46
+ <svg
47
+ width={size}
48
+ height={size}
49
+ viewBox="0 0 24 24"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ stroke-width="2"
53
+ stroke-linecap="round"
54
+ stroke-linejoin="round"
55
+ class={className}
56
+ >
57
+ <path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
58
+ <rect x="2" y="6" width="14" height="12" rx="2" />
59
+ </svg>
60
+ {:else if name === 'monitor'}
61
+ <svg
62
+ width={size}
63
+ height={size}
64
+ viewBox="0 0 24 24"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ stroke-width="2"
68
+ stroke-linecap="round"
69
+ stroke-linejoin="round"
70
+ class={className}
71
+ >
72
+ <rect width="20" height="14" x="2" y="3" rx="2" />
73
+ <line x1="8" x2="16" y1="21" y2="21" />
74
+ <line x1="12" x2="12" y1="17" y2="21" />
75
+ </svg>
76
+ {:else if name === 'square'}
77
+ <svg
78
+ width={size}
79
+ height={size}
80
+ viewBox="0 0 24 24"
81
+ fill="currentColor"
82
+ class={className}
83
+ >
84
+ <rect width="18" height="18" x="3" y="3" rx="2" />
85
+ </svg>
86
+ {:else if name === 'loader'}
87
+ <svg
88
+ width={size}
89
+ height={size}
90
+ viewBox="0 0 24 24"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ stroke-width="2"
94
+ stroke-linecap="round"
95
+ class={className}
96
+ >
97
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
98
+ </svg>
99
+ {:else if name === 'x'}
100
+ <svg
101
+ width={size}
102
+ height={size}
103
+ viewBox="0 0 24 24"
104
+ fill="none"
105
+ stroke="currentColor"
106
+ stroke-width="2"
107
+ stroke-linecap="round"
108
+ stroke-linejoin="round"
109
+ class={className}
110
+ >
111
+ <path d="M18 6 6 18" />
112
+ <path d="m6 6 12 12" />
113
+ </svg>
114
+ {:else if name === 'file'}
115
+ <svg
116
+ width={size}
117
+ height={size}
118
+ viewBox="0 0 24 24"
119
+ fill="none"
120
+ stroke="currentColor"
121
+ stroke-width="2"
122
+ stroke-linecap="round"
123
+ stroke-linejoin="round"
124
+ class={className}
125
+ >
126
+ <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
127
+ <path d="M14 2v4a2 2 0 0 0 2 2h4" />
128
+ </svg>
129
+ {:else if name === 'trash'}
130
+ <svg
131
+ width={size}
132
+ height={size}
133
+ viewBox="0 0 24 24"
134
+ fill="none"
135
+ stroke="currentColor"
136
+ stroke-width="2"
137
+ stroke-linecap="round"
138
+ stroke-linejoin="round"
139
+ class={className}
140
+ >
141
+ <path d="M3 6h18" />
142
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
143
+ </svg>
144
+ {:else if name === 'plus'}
145
+ <svg
146
+ width={size}
147
+ height={size}
148
+ viewBox="0 0 24 24"
149
+ fill="none"
150
+ stroke="currentColor"
151
+ stroke-width="2"
152
+ stroke-linecap="round"
153
+ stroke-linejoin="round"
154
+ class={className}
155
+ >
156
+ <path d="M5 12h14" />
157
+ <path d="M12 5v14" />
158
+ </svg>
159
+ {:else if name === 'type'}
160
+ <svg
161
+ width={size}
162
+ height={size}
163
+ viewBox="0 0 24 24"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ stroke-width="2"
167
+ stroke-linecap="round"
168
+ stroke-linejoin="round"
169
+ class={className}
170
+ >
171
+ <path d="M4 7V4h16v3" />
172
+ <path d="M9 20h6" />
173
+ <path d="M12 4v16" />
174
+ </svg>
175
+ {/if}
@@ -0,0 +1,8 @@
1
+ type $$ComponentProps = {
2
+ name: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash' | 'plus' | 'type';
3
+ size?: number;
4
+ class?: string;
5
+ };
6
+ declare const MediaCaptureIcon: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type MediaCaptureIcon = ReturnType<typeof MediaCaptureIcon>;
8
+ export default MediaCaptureIcon;
@@ -0,0 +1,168 @@
1
+ <script lang="ts">
2
+ /**
3
+ * MultimodalCaptureField — "upload a file, or record yourself going over
4
+ * it" as one input surface.
5
+ *
6
+ * Composes FileDropzone (capped to a single file — this is not a bulk
7
+ * upload zone) with MediaCapture, framed as one combined affordance rather
8
+ * than two components a consumer has to remember to place side by side.
9
+ * The two halves are independent: a file, a recording, both, or neither
10
+ * can be attached. Neither half performs an upload — this component hands
11
+ * back a plain `File` and/or a `MediaCaptureResult` blob via callbacks
12
+ * (`onFileSelected` / `onCapture`), same "validate and hand back, don't
13
+ * own the network" contract as FileDropzone itself. The host app owns
14
+ * whatever upload/transcription pipeline it needs.
15
+ */
16
+ import FileDropzone from './FileDropzone.svelte'
17
+ import MediaCapture from './MediaCapture.svelte'
18
+ import MediaCaptureIcon from './MediaCaptureIcon.svelte'
19
+ import type { MediaCaptureKind, MediaCaptureResult } from './MediaCapture.svelte'
20
+
21
+ interface Props {
22
+ label?: string
23
+ hint?: string
24
+ /** File MIME allow-list, forwarded to FileDropzone. */
25
+ accept?: string[]
26
+ maxFileSize?: number
27
+ dropLabel?: string
28
+ /** Which recording modes to offer. Default: all three. */
29
+ modes?: MediaCaptureKind[]
30
+ maxDurationMs?: number
31
+ /** Currently attached file, for re-rendering an already-selected/uploaded
32
+ * state (e.g. after a page reload with a persisted answer). */
33
+ file?: File | null
34
+ fileName?: string
35
+ fileUrl?: string
36
+ /** Currently attached recording's display info, if the host wants to
37
+ * show a persisted-answer summary (bindable is left to the host — this
38
+ * is a display prop, not a live capture in progress). */
39
+ recordingSummary?: { kind: MediaCaptureKind; durationSeconds: number | null } | null
40
+ onFileSelected?: (file: File) => void
41
+ onFileRemoved?: () => void
42
+ onCapture?: (result: MediaCaptureResult) => void | Promise<void>
43
+ onRecordingRemoved?: () => void
44
+ disabled?: boolean
45
+ class?: string
46
+ }
47
+
48
+ let {
49
+ label = 'Attach a file, or record yourself going over it',
50
+ hint,
51
+ accept = ['image/png', 'image/jpeg', 'application/pdf'],
52
+ maxFileSize = 8 * 1024 * 1024,
53
+ dropLabel = 'Upload a file',
54
+ modes = ['audio', 'video', 'screen'],
55
+ maxDurationMs = 120_000,
56
+ file = null,
57
+ fileName,
58
+ fileUrl,
59
+ recordingSummary = null,
60
+ onFileSelected,
61
+ onFileRemoved,
62
+ onCapture,
63
+ onRecordingRemoved,
64
+ disabled = false,
65
+ class: className = ''
66
+ }: Props = $props()
67
+
68
+ function handleFilesSelected(files: File[], errors: string[]) {
69
+ if (errors.length) fileError = errors[0]
70
+ else fileError = ''
71
+ if (files[0]) onFileSelected?.(files[0])
72
+ }
73
+
74
+ let fileError = $state('')
75
+
76
+ async function handleCapture(result: MediaCaptureResult) {
77
+ await onCapture?.(result)
78
+ }
79
+
80
+ function modeLabel(kind: MediaCaptureKind): string {
81
+ return kind === 'audio' ? 'Voice note' : kind === 'video' ? 'Video note' : 'Screen recording'
82
+ }
83
+
84
+ function formatDuration(sec: number | null): string {
85
+ if (sec == null) return ''
86
+ const m = Math.floor(sec / 60)
87
+ const s = Math.round(sec % 60)
88
+ return ` (${m}:${String(s).padStart(2, '0')})`
89
+ }
90
+ </script>
91
+
92
+ <div class="multimodal-capture-field {className}">
93
+ {#if label || hint}
94
+ <div class="mb-2">
95
+ {#if label}<p class="text-base-content text-sm font-medium">{label}</p>{/if}
96
+ {#if hint}<p class="text-base-content/60 text-xs">{hint}</p>{/if}
97
+ </div>
98
+ {/if}
99
+
100
+ {#if file || fileUrl}
101
+ <div class="rounded-field bg-base-200 border-base-300 flex items-center gap-2 border p-2.5">
102
+ <MediaCaptureIcon name="file" size={16} class="text-base-content/50 flex-shrink-0" />
103
+ <span class="text-base-content/80 min-w-0 flex-1 truncate text-sm">
104
+ {fileName || file?.name}
105
+ </span>
106
+ {#if onFileRemoved}
107
+ <button
108
+ type="button"
109
+ class="text-base-content/40 hover:text-error flex-shrink-0"
110
+ onclick={onFileRemoved}
111
+ aria-label="Remove file"
112
+ >
113
+ <MediaCaptureIcon name="x" size={14} />
114
+ </button>
115
+ {/if}
116
+ </div>
117
+ {:else}
118
+ <FileDropzone
119
+ {accept}
120
+ maxSize={maxFileSize}
121
+ maxFiles={1}
122
+ multiple={false}
123
+ {dropLabel}
124
+ {disabled}
125
+ onFilesSelected={handleFilesSelected}
126
+ />
127
+ {#if fileError}
128
+ <p class="text-warning mt-1.5 text-xs">{fileError}</p>
129
+ {/if}
130
+ {/if}
131
+
132
+ <div class="divider text-base-content/40 my-3 text-xs">or</div>
133
+
134
+ {#if recordingSummary}
135
+ <div class="rounded-field bg-base-200 border-base-300 flex items-center gap-2 border p-2.5">
136
+ <MediaCaptureIcon
137
+ name={recordingSummary.kind === 'audio'
138
+ ? 'mic'
139
+ : recordingSummary.kind === 'video'
140
+ ? 'video'
141
+ : 'monitor'}
142
+ size={16}
143
+ class="text-base-content/50 flex-shrink-0"
144
+ />
145
+ <span class="text-base-content/80 min-w-0 flex-1 truncate text-sm">
146
+ {modeLabel(recordingSummary.kind)}{formatDuration(recordingSummary.durationSeconds)}
147
+ </span>
148
+ {#if onRecordingRemoved}
149
+ <button
150
+ type="button"
151
+ class="text-base-content/40 hover:text-error flex-shrink-0"
152
+ onclick={onRecordingRemoved}
153
+ aria-label="Remove recording"
154
+ >
155
+ <MediaCaptureIcon name="x" size={14} />
156
+ </button>
157
+ {/if}
158
+ </div>
159
+ {:else}
160
+ <MediaCapture
161
+ {modes}
162
+ {maxDurationMs}
163
+ {disabled}
164
+ label=""
165
+ onCapture={handleCapture}
166
+ />
167
+ {/if}
168
+ </div>
@@ -0,0 +1,33 @@
1
+ import type { MediaCaptureKind, MediaCaptureResult } from './MediaCapture.svelte';
2
+ interface Props {
3
+ label?: string;
4
+ hint?: string;
5
+ /** File MIME allow-list, forwarded to FileDropzone. */
6
+ accept?: string[];
7
+ maxFileSize?: number;
8
+ dropLabel?: string;
9
+ /** Which recording modes to offer. Default: all three. */
10
+ modes?: MediaCaptureKind[];
11
+ maxDurationMs?: number;
12
+ /** Currently attached file, for re-rendering an already-selected/uploaded
13
+ * state (e.g. after a page reload with a persisted answer). */
14
+ file?: File | null;
15
+ fileName?: string;
16
+ fileUrl?: string;
17
+ /** Currently attached recording's display info, if the host wants to
18
+ * show a persisted-answer summary (bindable is left to the host — this
19
+ * is a display prop, not a live capture in progress). */
20
+ recordingSummary?: {
21
+ kind: MediaCaptureKind;
22
+ durationSeconds: number | null;
23
+ } | null;
24
+ onFileSelected?: (file: File) => void;
25
+ onFileRemoved?: () => void;
26
+ onCapture?: (result: MediaCaptureResult) => void | Promise<void>;
27
+ onRecordingRemoved?: () => void;
28
+ disabled?: boolean;
29
+ class?: string;
30
+ }
31
+ declare const MultimodalCaptureField: import("svelte").Component<Props, {}, "">;
32
+ type MultimodalCaptureField = ReturnType<typeof MultimodalCaptureField>;
33
+ export default MultimodalCaptureField;
package/dist/index.d.ts CHANGED
@@ -89,6 +89,9 @@ export { EMOJI_DATA, ALL_EMOJIS, searchEmojis } from './data/emojis';
89
89
  export type { EmojiEntry, EmojiGroup } from './data/emojis';
90
90
  export { default as LinkShortener } from './components/LinkShortener.svelte';
91
91
  export { default as MediaWorkbench } from './components/MediaWorkbench.svelte';
92
+ export { default as MediaCapture } from './components/MediaCapture.svelte';
93
+ export { default as MultimodalCaptureField } from './components/MultimodalCaptureField.svelte';
94
+ export type { MediaCaptureKind, MediaCaptureResult } from './components/MediaCapture.svelte';
92
95
  export { default as SignaturePad } from './components/SignaturePad.svelte';
93
96
  export { default as MilestoneCard } from './components/MilestoneCard.svelte';
94
97
  export { default as MilestoneTimeline } from './components/MilestoneTimeline.svelte';
package/dist/index.js CHANGED
@@ -94,6 +94,8 @@ export { EMOJI_DATA, ALL_EMOJIS, searchEmojis } from './data/emojis';
94
94
  export { default as LinkShortener } from './components/LinkShortener.svelte';
95
95
  // Components — Media
96
96
  export { default as MediaWorkbench } from './components/MediaWorkbench.svelte';
97
+ export { default as MediaCapture } from './components/MediaCapture.svelte';
98
+ export { default as MultimodalCaptureField } from './components/MultimodalCaptureField.svelte';
97
99
  // Components — from Marduk
98
100
  export { default as SignaturePad } from './components/SignaturePad.svelte';
99
101
  export { default as MilestoneCard } from './components/MilestoneCard.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -75,7 +75,7 @@
75
75
  "package": "svelte-package --input src/lib --output dist && node scripts/compile-rune-modules.mjs",
76
76
  "check:demos": "node scripts/check-demo-coverage.mjs",
77
77
  "guard": "node scripts/check-demo-coverage.mjs",
78
- "test:dist": "vitest run --config vitest.dist.config.ts",
78
+ "test:dist": "svelte-kit sync && vitest run --config vitest.dist.config.ts",
79
79
  "prepublishOnly": "node scripts/guard-ci-publish.mjs && npm run package"
80
80
  },
81
81
  "peerDependencies": {
@@ -158,6 +158,7 @@
158
158
  "@tailwindcss/vite": "^4.0.0",
159
159
  "@tanstack/table-core": "^8.21.3",
160
160
  "@types/d3": "^7.4.3",
161
+ "@vitest/browser": "^3.2.7",
161
162
  "@xyflow/svelte": "^1.6.2",
162
163
  "cobe": "^2.0.1",
163
164
  "d3": "^7.9.0",
@@ -166,10 +167,12 @@
166
167
  "lenis": "^1.3.25",
167
168
  "livekit-client": "^2.20.1",
168
169
  "motion": "^12.42.2",
170
+ "playwright": "^1.62.1",
169
171
  "svelte": "^5.0.0",
170
172
  "tailwindcss": "^4.0.0",
171
173
  "typescript": "^5.0.0",
172
- "vite": "^6.0.0"
174
+ "vite": "^6.0.0",
175
+ "vitest": "^3.2.7"
173
176
  },
174
177
  "dependencies": {
175
178
  "@floating-ui/dom": "^1.6.0",