@joewinke/jatui 0.32.0 → 0.34.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 (28) hide show
  1. package/CLAUDE.md +16 -1
  2. package/dist/actions/reviewKeys.d.ts +159 -0
  3. package/dist/actions/reviewKeys.js +353 -0
  4. package/dist/components/VideoPlayer.svelte +437 -68
  5. package/dist/components/VideoPlayer.svelte.d.ts +17 -0
  6. package/dist/components/review/MediaZoomLightbox.svelte +628 -0
  7. package/dist/components/review/MediaZoomLightbox.svelte.d.ts +56 -0
  8. package/dist/components/review/ReviewFilterBar.svelte +296 -0
  9. package/dist/components/review/ReviewFilterBar.svelte.d.ts +45 -0
  10. package/dist/components/review/ReviewNote.svelte +213 -0
  11. package/dist/components/review/ReviewNote.svelte.d.ts +37 -0
  12. package/dist/components/review/ReviewProgress.svelte +99 -0
  13. package/dist/components/review/ReviewProgress.svelte.d.ts +32 -0
  14. package/dist/components/review/ReviewShortcuts.svelte +80 -0
  15. package/dist/components/review/ReviewShortcuts.svelte.d.ts +15 -0
  16. package/dist/components/review/RoundHistory.svelte +151 -0
  17. package/dist/components/review/RoundHistory.svelte.d.ts +35 -0
  18. package/dist/components/review/VerdictControl.svelte +163 -0
  19. package/dist/components/review/VerdictControl.svelte.d.ts +23 -0
  20. package/dist/components/review/index.d.ts +24 -0
  21. package/dist/components/review/index.js +23 -0
  22. package/dist/components/review/mediaZoom.d.ts +115 -0
  23. package/dist/components/review/mediaZoom.js +161 -0
  24. package/dist/components/review/reviewSummary.d.ts +101 -0
  25. package/dist/components/review/reviewSummary.js +122 -0
  26. package/dist/index.d.ts +14 -0
  27. package/dist/index.js +12 -0
  28. package/package.json +6 -1
@@ -73,6 +73,23 @@ import type { ResumePoint } from '../video/advisors/resume-point';
73
73
  import { type SettingValue, type VideoSettingsStore } from '../video/settings';
74
74
  type $$ComponentProps = {
75
75
  src: string;
76
+ /**
77
+ * What the media IS. `'audio'` (a podcast, a call recording, a brief)
78
+ * renders NO stage: there is no picture to frame, so the player is the
79
+ * band (the one timeline) plus its transport row. The media element
80
+ * still plays, just never shows. `aspect`/`poster` are ignored. Default
81
+ * `'video'`.
82
+ */
83
+ kind?: 'video' | 'audio';
84
+ /**
85
+ * Who owns play/pause, time and volume. `'native'` keeps the browser's
86
+ * `<video controls>` bar (which carries its own timeline next to the
87
+ * band). `'band'` drops the native controls and puts a transport row
88
+ * on the band instead: play/pause, `current / total`, speed, mute +
89
+ * volume, and fullscreen for video. One timeline, one set of controls.
90
+ * Default: `'band'` for `kind="audio"`, `'native'` for video.
91
+ */
92
+ transport?: 'native' | 'band';
76
93
  poster?: string | null;
77
94
  /**
78
95
  * Aspect ratio for the video stage — presentation-primitive prop (PRD).
@@ -0,0 +1,628 @@
1
+ <script lang="ts">
2
+ /**
3
+ * MediaZoomLightbox — one full-viewport viewer for images AND video
4
+ * (jatui-c79nt; design: mardukos docs/review-ux/proposal.md, mos-lsmve).
5
+ *
6
+ * Ported from the MOS media-picker ZoomLightbox (mos-0wvol): zoom/pan that
7
+ * applies to a PLAYING video (+ / − / 0, wheel, pinch, drag), frame-step
8
+ * (, .), speed (< > or the chips), loop, scrub; ←/→ page the item's views,
9
+ * 1–9 pick one. Images get the same zoom.
10
+ *
11
+ * Reviewing inside the zoom view (decision D5=A): the `footer` snippet
12
+ * renders in the bottom bar, so a host drops VerdictControl + ReviewNote
13
+ * there and the reviewer decides without closing. The lightbox imports
14
+ * neither. Keys it doesn't own (a/x/r/u/n, [ ], ?) bubble out to the host.
15
+ * `onNextItem` / `onPrevItem` let the host page to the next item while
16
+ * open (j/k, ↓/↑, or ←/→ past the last/first view): the host swaps `items`
17
+ * and the viewer resets to the new item's first (or, going back, last) view.
18
+ *
19
+ * Focus moves into the dialog on mount, Tab is trapped, Esc closes and focus
20
+ * returns to whatever had it before. Mount it to open, unmount to close.
21
+ * No autoplay under prefers-reduced-motion. Token-only theming.
22
+ */
23
+ import { onMount, tick, untrack, type Snippet } from 'svelte';
24
+ import type { ShortcutRegistry } from '../../utils/keyboardShortcutRegistry';
25
+ import { isTypingTarget } from '../../utils/keyboardShortcutRegistry';
26
+ import {
27
+ DEFAULT_FPS,
28
+ DEFAULT_SPEEDS,
29
+ ZOOM_IDENTITY,
30
+ ZOOM_STEP,
31
+ clampIndex,
32
+ formatMediaTime,
33
+ mediaZoomHint,
34
+ panBy,
35
+ resolveMediaZoomKey,
36
+ stepFrameTime,
37
+ stepSpeed,
38
+ stepView,
39
+ wheelFactor,
40
+ zoomAt,
41
+ zoomTransform,
42
+ type MediaZoomFooterContext,
43
+ type MediaZoomState,
44
+ type MediaZoomView
45
+ } from './mediaZoom';
46
+
47
+ interface Props {
48
+ /** The views of the item being judged (one or more). */
49
+ items: MediaZoomView[];
50
+ /** Current view (bindable). Seeded from the host; clamped to `items`. */
51
+ index?: number;
52
+ /** Item name shown before the view label ("Grease trap card"). */
53
+ title?: string;
54
+ /** Extra header text after the view counter, e.g. "item 3 of 12". */
55
+ position?: string;
56
+ onClose: () => void;
57
+ /** Host pages to the next item. Omit (or pass undefined at the end) to disable. */
58
+ onNextItem?: () => void;
59
+ onPrevItem?: () => void;
60
+ /** Bottom-bar slot — VerdictControl + ReviewNote live here in review hosts. */
61
+ footer?: Snippet<[MediaZoomFooterContext]>;
62
+ /** Frame-step rate. Review loops are 30fps. */
63
+ fps?: number;
64
+ speeds?: readonly number[];
65
+ /** Autoplay video (always off under prefers-reduced-motion). */
66
+ autoplay?: boolean;
67
+ muted?: boolean;
68
+ /** Show the one-line key hint under the controls. */
69
+ showHint?: boolean;
70
+ zIndex?: string | number;
71
+ /** While open, push a modal context so page shortcuts don't fire behind it. */
72
+ shortcutRegistry?: ShortcutRegistry;
73
+ shortcutContext?: string;
74
+ class?: string;
75
+ }
76
+
77
+ let {
78
+ items,
79
+ index = $bindable(0),
80
+ title = '',
81
+ position = '',
82
+ onClose,
83
+ onNextItem,
84
+ onPrevItem,
85
+ footer,
86
+ fps = DEFAULT_FPS,
87
+ speeds = DEFAULT_SPEEDS,
88
+ autoplay = true,
89
+ muted = true,
90
+ showHint = true,
91
+ zIndex = 9040,
92
+ shortcutRegistry,
93
+ shortcutContext = 'media-zoom',
94
+ class: className = ''
95
+ }: Props = $props();
96
+
97
+ let zoom = $state<MediaZoomState>({ ...ZOOM_IDENTITY });
98
+ let paused = $state(true);
99
+ let looping = $state(true);
100
+ let speed = $state(1);
101
+ let time = $state(0);
102
+ let duration = $state(0);
103
+ let dialogEl = $state<HTMLElement | null>(null);
104
+ let stageEl = $state<HTMLElement | null>(null);
105
+ let videoEl = $state<HTMLVideoElement | null>(null);
106
+
107
+ const safeIndex = $derived(clampIndex(index, items.length));
108
+ const current = $derived(items[safeIndex]);
109
+ const isVideo = $derived(current?.kind === 'video');
110
+ const hasItems = $derived(!!onNextItem || !!onPrevItem);
111
+ const keyCtx = $derived({ isVideo, hasItems, viewCount: items.length });
112
+ const zoomPct = $derived(Math.round(zoom.scale * 100));
113
+ const hint = $derived(mediaZoomHint(keyCtx));
114
+
115
+ function resetView() {
116
+ zoom = { ...ZOOM_IDENTITY };
117
+ time = 0;
118
+ duration = 0;
119
+ }
120
+
121
+ // The host swapped `items` (paged to another item): land on the edge we
122
+ // came in from, with a fresh zoom.
123
+ let seenItems: MediaZoomView[] = untrack(() => items);
124
+ let enterAt: 'start' | 'end' | null = null;
125
+ $effect.pre(() => {
126
+ const next = items;
127
+ if (next === seenItems) return;
128
+ seenItems = next;
129
+ untrack(() => {
130
+ if (enterAt === 'end') index = Math.max(0, next.length - 1);
131
+ else if (enterAt === 'start') index = 0;
132
+ else index = clampIndex(index, next.length);
133
+ enterAt = null;
134
+ resetView();
135
+ });
136
+ });
137
+
138
+ function reducedMotion(): boolean {
139
+ try {
140
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ const shouldAutoplay = $derived(autoplay && !reducedMotion());
146
+
147
+ function stageSize(): { w: number; h: number } {
148
+ const r = stageEl?.getBoundingClientRect();
149
+ return { w: r?.width ?? 0, h: r?.height ?? 0 };
150
+ }
151
+
152
+ function zoomBy(factor: number, cx = 0, cy = 0) {
153
+ const { w, h } = stageSize();
154
+ zoom = zoomAt(zoom, factor, cx, cy, w, h);
155
+ }
156
+
157
+ function resetZoom() {
158
+ zoom = { ...ZOOM_IDENTITY };
159
+ }
160
+
161
+ function setView(i: number) {
162
+ if (i === safeIndex) return;
163
+ index = clampIndex(i, items.length);
164
+ resetView();
165
+ }
166
+
167
+ function pageItem(dir: 1 | -1) {
168
+ const cb = dir > 0 ? onNextItem : onPrevItem;
169
+ if (!cb) return;
170
+ enterAt = dir > 0 ? 'start' : 'end';
171
+ cb();
172
+ }
173
+
174
+ function go(delta: 1 | -1) {
175
+ const s = stepView(safeIndex, delta, items.length, {
176
+ hasNextItem: !!onNextItem,
177
+ hasPrevItem: !!onPrevItem
178
+ });
179
+ if (s.kind === 'view') setView(s.index);
180
+ else if (s.kind === 'item') pageItem(s.dir);
181
+ }
182
+
183
+ function togglePlay() {
184
+ const v = videoEl;
185
+ if (!v) return;
186
+ if (v.paused) v.play().catch(() => {});
187
+ else v.pause();
188
+ }
189
+
190
+ function step(frames: number) {
191
+ const v = videoEl;
192
+ if (!v) return;
193
+ v.pause();
194
+ v.currentTime = stepFrameTime(v.currentTime, frames, v.duration, fps);
195
+ time = v.currentTime;
196
+ }
197
+
198
+ function setSpeed(s: number) {
199
+ speed = s;
200
+ if (videoEl) videoEl.playbackRate = s;
201
+ }
202
+
203
+ function seek(sec: number) {
204
+ if (!videoEl) return;
205
+ videoEl.currentTime = sec;
206
+ time = sec;
207
+ }
208
+
209
+ const ARROW_OWNERS =
210
+ 'input, select, textarea, [contenteditable], [role="radio"], [role="slider"], [role="tab"], [role="option"], [role="menuitem"], [role="radiogroup"] *, [role="listbox"] *';
211
+ const ACTIVATABLE = 'button, a[href], input, select, textarea, summary, [role="button"], [role="radio"], [role="checkbox"], [role="switch"], [role="tab"], [role="option"], [role="menuitem"]';
212
+
213
+ function onKeydown(e: KeyboardEvent) {
214
+ if (e.defaultPrevented || e.isComposing) return;
215
+ const k = e.key;
216
+ if (k === 'Tab') {
217
+ trapTab(e);
218
+ return;
219
+ }
220
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
221
+ const target = e.target instanceof HTMLElement ? e.target : null;
222
+ const onDialog = target === dialogEl;
223
+ if (target && !onDialog && isTypingTarget(target)) {
224
+ // Typing in the footer's note: keys are text. Esc leaves the field
225
+ // (back to the viewer) instead of closing the whole lightbox.
226
+ if (k === 'Escape') {
227
+ e.preventDefault();
228
+ e.stopPropagation();
229
+ dialogEl?.focus();
230
+ }
231
+ return;
232
+ }
233
+ if (target && !onDialog) {
234
+ // A focused control keeps its own activation and arrow keys.
235
+ if ((k === ' ' || k === 'Enter') && target.matches(ACTIVATABLE)) return;
236
+ if (k.startsWith('Arrow') && target.closest(ARROW_OWNERS)) return;
237
+ }
238
+ const action = resolveMediaZoomKey(k, keyCtx);
239
+ if (!action) return; // not ours — bubbles to the host (verdict keys, ?, [ ])
240
+ e.preventDefault();
241
+ e.stopPropagation();
242
+ switch (action) {
243
+ case 'close':
244
+ onClose();
245
+ break;
246
+ case 'next-view':
247
+ go(1);
248
+ break;
249
+ case 'prev-view':
250
+ go(-1);
251
+ break;
252
+ case 'next-item':
253
+ pageItem(1);
254
+ break;
255
+ case 'prev-item':
256
+ pageItem(-1);
257
+ break;
258
+ case 'pick-view':
259
+ setView(Number(k) - 1);
260
+ break;
261
+ case 'zoom-in':
262
+ zoomBy(ZOOM_STEP);
263
+ break;
264
+ case 'zoom-out':
265
+ zoomBy(1 / ZOOM_STEP);
266
+ break;
267
+ case 'zoom-reset':
268
+ resetZoom();
269
+ break;
270
+ case 'play-pause':
271
+ togglePlay();
272
+ break;
273
+ case 'frame-back':
274
+ step(-1);
275
+ break;
276
+ case 'frame-forward':
277
+ step(1);
278
+ break;
279
+ case 'loop':
280
+ looping = !looping;
281
+ break;
282
+ case 'slower':
283
+ setSpeed(stepSpeed(speed, -1, speeds));
284
+ break;
285
+ case 'faster':
286
+ setSpeed(stepSpeed(speed, 1, speeds));
287
+ break;
288
+ }
289
+ }
290
+
291
+ function trapTab(e: KeyboardEvent) {
292
+ if (!dialogEl) return;
293
+ const f = [
294
+ ...dialogEl.querySelectorAll<HTMLElement>(
295
+ 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
296
+ )
297
+ ].filter((el) => !el.hidden && !el.closest('[hidden], [inert]'));
298
+ if (f.length === 0) {
299
+ e.preventDefault();
300
+ dialogEl.focus();
301
+ return;
302
+ }
303
+ const first = f[0];
304
+ const last = f[f.length - 1];
305
+ const active = document.activeElement;
306
+ if (e.shiftKey && (active === first || active === dialogEl)) {
307
+ e.preventDefault();
308
+ last.focus();
309
+ } else if (!e.shiftKey && (active === last || !dialogEl.contains(active))) {
310
+ e.preventDefault();
311
+ first.focus();
312
+ }
313
+ }
314
+
315
+ // --- pointer: drag to pan, pinch to zoom, click to play/pause -------------
316
+
317
+ const pointers = new Map<number, { x: number; y: number }>();
318
+ let moved = 0;
319
+ let pinchDist = 0;
320
+
321
+ function centred(clientX: number, clientY: number) {
322
+ const r = stageEl?.getBoundingClientRect();
323
+ if (!r) return { cx: 0, cy: 0 };
324
+ return { cx: clientX - (r.left + r.width / 2), cy: clientY - (r.top + r.height / 2) };
325
+ }
326
+
327
+ function onPointerDown(e: PointerEvent) {
328
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
329
+ moved = 0;
330
+ try {
331
+ stageEl?.setPointerCapture?.(e.pointerId);
332
+ } catch {
333
+ /* jsdom / detached */
334
+ }
335
+ if (pointers.size === 2) {
336
+ const [a, b] = [...pointers.values()];
337
+ pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
338
+ }
339
+ }
340
+
341
+ function onPointerMove(e: PointerEvent) {
342
+ const prev = pointers.get(e.pointerId);
343
+ if (!prev) return;
344
+ const next = { x: e.clientX, y: e.clientY };
345
+ pointers.set(e.pointerId, next);
346
+ const { w, h } = stageSize();
347
+ if (pointers.size >= 2) {
348
+ const [a, b] = [...pointers.values()];
349
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
350
+ if (pinchDist > 0 && d > 0) {
351
+ const mid = centred((a.x + b.x) / 2, (a.y + b.y) / 2);
352
+ zoom = zoomAt(zoom, d / pinchDist, mid.cx, mid.cy, w, h);
353
+ }
354
+ pinchDist = d;
355
+ moved += 10;
356
+ return;
357
+ }
358
+ const dx = next.x - prev.x;
359
+ const dy = next.y - prev.y;
360
+ moved += Math.abs(dx) + Math.abs(dy);
361
+ zoom = panBy(zoom, dx, dy, w, h);
362
+ }
363
+
364
+ function onPointerUp(e: PointerEvent) {
365
+ const wasTap = pointers.size === 1 && moved < 4;
366
+ pointers.delete(e.pointerId);
367
+ if (pointers.size < 2) pinchDist = 0;
368
+ if (wasTap && isVideo && e.pointerType !== 'touch') togglePlay();
369
+ }
370
+
371
+ $effect(() => {
372
+ const el = stageEl;
373
+ if (!el) return;
374
+ // Non-passive: the wheel zooms the media instead of scrolling the page.
375
+ const onWheel = (e: WheelEvent) => {
376
+ e.preventDefault();
377
+ const { cx, cy } = centred(e.clientX, e.clientY);
378
+ zoomBy(wheelFactor(e.deltaY), cx, cy);
379
+ };
380
+ el.addEventListener('wheel', onWheel, { passive: false });
381
+ return () => el.removeEventListener('wheel', onWheel);
382
+ });
383
+
384
+ // Keep the element in step with the chosen speed/loop across view changes.
385
+ $effect(() => {
386
+ const v = videoEl;
387
+ if (!v) return;
388
+ v.playbackRate = speed;
389
+ v.loop = looping;
390
+ });
391
+
392
+ $effect(() => {
393
+ if (!shortcutRegistry) return;
394
+ return shortcutRegistry.pushContext(shortcutContext, {
395
+ modal: true,
396
+ label: untrack(() => title) || 'Media viewer'
397
+ });
398
+ });
399
+
400
+ onMount(() => {
401
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
402
+ void tick().then(() => dialogEl?.focus());
403
+ const prevOverflow = document.body.style.overflow;
404
+ document.body.style.overflow = 'hidden';
405
+ return () => {
406
+ document.body.style.overflow = prevOverflow;
407
+ if (previousFocus?.isConnected) previousFocus.focus();
408
+ };
409
+ });
410
+ </script>
411
+
412
+ <div
413
+ bind:this={dialogEl}
414
+ class="fixed inset-0 flex flex-col bg-base-300/95 text-base-content outline-none {className}"
415
+ style:z-index={zIndex}
416
+ role="dialog"
417
+ aria-modal="true"
418
+ aria-label="{title ? `${title}: ` : ''}{current?.label ?? 'media'}, full size"
419
+ tabindex="-1"
420
+ data-media-zoom-lightbox
421
+ onkeydown={onKeydown}
422
+ >
423
+ <header class="flex items-center gap-2 px-3 py-2 sm:px-4">
424
+ <p class="min-w-0 flex-1 truncate text-sm" data-media-zoom-caption>
425
+ {#if title}<span class="font-semibold">{title}</span>{' · '}{/if}<span>{current?.label}</span
426
+ >{#if items.length > 1}<span class="tabular-nums text-base-content/70"
427
+ >{' · '}{safeIndex + 1} of {items.length}</span
428
+ >{/if}{#if position}<span class="text-base-content/70">{' · '}{position}</span>{/if}
429
+ </p>
430
+ {#if onPrevItem || onNextItem}
431
+ <button
432
+ type="button"
433
+ class="btn btn-ghost btn-sm rounded-field"
434
+ aria-label="Previous item"
435
+ aria-keyshortcuts="K"
436
+ disabled={!onPrevItem}
437
+ onclick={() => pageItem(-1)}>↑</button
438
+ >
439
+ <button
440
+ type="button"
441
+ class="btn btn-ghost btn-sm rounded-field"
442
+ aria-label="Next item"
443
+ aria-keyshortcuts="J"
444
+ disabled={!onNextItem}
445
+ onclick={() => pageItem(1)}>↓</button
446
+ >
447
+ {/if}
448
+ {#if items.length > 1}
449
+ <button
450
+ type="button"
451
+ class="btn btn-ghost btn-sm rounded-field"
452
+ aria-label="Previous view"
453
+ aria-keyshortcuts="ArrowLeft"
454
+ onclick={() => go(-1)}>←</button
455
+ >
456
+ <button
457
+ type="button"
458
+ class="btn btn-ghost btn-sm rounded-field"
459
+ aria-label="Next view"
460
+ aria-keyshortcuts="ArrowRight"
461
+ onclick={() => go(1)}>→</button
462
+ >
463
+ {/if}
464
+ <button
465
+ type="button"
466
+ class="btn btn-ghost btn-sm rounded-field"
467
+ aria-label="Close full size view"
468
+ aria-keyshortcuts="Escape"
469
+ onclick={onClose}>✕</button
470
+ >
471
+ </header>
472
+
473
+ <!-- The stage: absolute-inset media (Firefox h-full in flex chains is
474
+ broken), transformed for zoom/pan. -->
475
+ <div
476
+ bind:this={stageEl}
477
+ data-zoom-stage
478
+ class="relative min-h-0 flex-1 touch-none select-none overflow-hidden {zoom.scale > 1
479
+ ? 'cursor-grab active:cursor-grabbing'
480
+ : isVideo
481
+ ? 'cursor-pointer'
482
+ : ''}"
483
+ role="presentation"
484
+ onpointerdown={onPointerDown}
485
+ onpointermove={onPointerMove}
486
+ onpointerup={onPointerUp}
487
+ onpointercancel={onPointerUp}
488
+ >
489
+ {#key current}
490
+ <div
491
+ data-zoom-layer
492
+ class="absolute inset-0 grid place-items-center"
493
+ style:transform={zoomTransform(zoom)}
494
+ >
495
+ {#if current?.kind === 'video'}
496
+ <!-- svelte-ignore a11y_media_has_caption -->
497
+ <video
498
+ bind:this={videoEl}
499
+ src={current.src}
500
+ poster={current.poster}
501
+ {muted}
502
+ playsinline
503
+ loop={looping}
504
+ autoplay={shouldAutoplay}
505
+ preload="auto"
506
+ aria-label={current.label}
507
+ class="max-h-full max-w-full object-contain"
508
+ onplay={() => (paused = false)}
509
+ onpause={() => (paused = true)}
510
+ ontimeupdate={(e) => (time = e.currentTarget.currentTime)}
511
+ onloadedmetadata={(e) => {
512
+ duration = e.currentTarget.duration || 0;
513
+ e.currentTarget.playbackRate = speed;
514
+ }}
515
+ ></video>
516
+ {:else if current}
517
+ <img
518
+ src={current.src}
519
+ alt={current.alt ?? current.label}
520
+ draggable="false"
521
+ class="max-h-full max-w-full object-contain"
522
+ />
523
+ {/if}
524
+ </div>
525
+ {/key}
526
+ </div>
527
+
528
+ <div
529
+ class="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-base-content/10 bg-base-200 px-3 py-2 text-sm sm:px-4"
530
+ data-media-zoom-controls
531
+ >
532
+ {#if isVideo}
533
+ <div class="flex items-center gap-1">
534
+ <button
535
+ type="button"
536
+ class="btn btn-sm rounded-field"
537
+ aria-label={paused ? 'Play' : 'Pause'}
538
+ aria-keyshortcuts="Space"
539
+ onclick={togglePlay}>{paused ? '▶' : '❚❚'}</button
540
+ >
541
+ <button
542
+ type="button"
543
+ class="btn btn-ghost btn-sm rounded-field tabular-nums"
544
+ aria-label="Back one frame"
545
+ aria-keyshortcuts=","
546
+ onclick={() => step(-1)}>‹ frame</button
547
+ >
548
+ <button
549
+ type="button"
550
+ class="btn btn-ghost btn-sm rounded-field tabular-nums"
551
+ aria-label="Forward one frame"
552
+ aria-keyshortcuts="."
553
+ onclick={() => step(1)}>frame ›</button
554
+ >
555
+ <button
556
+ type="button"
557
+ class="btn btn-ghost btn-sm rounded-field"
558
+ aria-pressed={looping}
559
+ aria-keyshortcuts="L"
560
+ onclick={() => (looping = !looping)}>{looping ? 'Loop on' : 'Loop off'}</button
561
+ >
562
+ </div>
563
+ <label class="flex min-w-48 flex-1 items-center gap-2">
564
+ <span class="sr-only">Scrub</span>
565
+ <input
566
+ type="range"
567
+ class="range range-xs range-primary flex-1"
568
+ min="0"
569
+ max={duration || 0}
570
+ step="any"
571
+ value={time}
572
+ aria-valuetext="{formatMediaTime(time)} of {formatMediaTime(duration)}"
573
+ oninput={(e) => seek(Number(e.currentTarget.value))}
574
+ />
575
+ <span class="text-xs tabular-nums text-base-content/70"
576
+ >{formatMediaTime(time)} / {formatMediaTime(duration)}</span
577
+ >
578
+ </label>
579
+ <div class="flex items-center gap-1" role="group" aria-label="Speed">
580
+ {#each speeds as s (s)}
581
+ <button
582
+ type="button"
583
+ class="btn btn-xs rounded-field {speed === s ? 'btn-primary' : 'btn-ghost'}"
584
+ aria-pressed={speed === s}
585
+ onclick={() => setSpeed(s)}>{s}×</button
586
+ >
587
+ {/each}
588
+ </div>
589
+ {/if}
590
+ <div class="ml-auto flex items-center gap-1" role="group" aria-label="Zoom">
591
+ <button
592
+ type="button"
593
+ class="btn btn-ghost btn-sm rounded-field"
594
+ aria-label="Zoom out"
595
+ aria-keyshortcuts="-"
596
+ disabled={zoom.scale <= 1}
597
+ onclick={() => zoomBy(1 / ZOOM_STEP)}>−</button
598
+ >
599
+ <button
600
+ type="button"
601
+ class="btn btn-ghost btn-sm min-w-14 rounded-field tabular-nums"
602
+ aria-label="Reset zoom ({zoomPct}%)"
603
+ aria-keyshortcuts="0"
604
+ data-zoom-level
605
+ onclick={resetZoom}>{zoomPct}%</button
606
+ >
607
+ <button
608
+ type="button"
609
+ class="btn btn-ghost btn-sm rounded-field"
610
+ aria-label="Zoom in"
611
+ aria-keyshortcuts="+"
612
+ onclick={() => zoomBy(ZOOM_STEP)}>+</button
613
+ >
614
+ </div>
615
+ {#if showHint}
616
+ <p class="w-full text-2xs text-base-content/60" data-media-zoom-hint>{hint}</p>
617
+ {/if}
618
+ </div>
619
+
620
+ {#if footer}
621
+ <footer
622
+ class="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-base-content/10 bg-base-100 px-3 py-2 sm:px-4"
623
+ data-media-zoom-footer
624
+ >
625
+ {@render footer({ view: current, index: safeIndex, isVideo })}
626
+ </footer>
627
+ {/if}
628
+ </div>
@@ -0,0 +1,56 @@
1
+ /**
2
+ * MediaZoomLightbox — one full-viewport viewer for images AND video
3
+ * (jatui-c79nt; design: mardukos docs/review-ux/proposal.md, mos-lsmve).
4
+ *
5
+ * Ported from the MOS media-picker ZoomLightbox (mos-0wvol): zoom/pan that
6
+ * applies to a PLAYING video (+ / − / 0, wheel, pinch, drag), frame-step
7
+ * (, .), speed (< > or the chips), loop, scrub; ←/→ page the item's views,
8
+ * 1–9 pick one. Images get the same zoom.
9
+ *
10
+ * Reviewing inside the zoom view (decision D5=A): the `footer` snippet
11
+ * renders in the bottom bar, so a host drops VerdictControl + ReviewNote
12
+ * there and the reviewer decides without closing. The lightbox imports
13
+ * neither. Keys it doesn't own (a/x/r/u/n, [ ], ?) bubble out to the host.
14
+ * `onNextItem` / `onPrevItem` let the host page to the next item while
15
+ * open (j/k, ↓/↑, or ←/→ past the last/first view): the host swaps `items`
16
+ * and the viewer resets to the new item's first (or, going back, last) view.
17
+ *
18
+ * Focus moves into the dialog on mount, Tab is trapped, Esc closes and focus
19
+ * returns to whatever had it before. Mount it to open, unmount to close.
20
+ * No autoplay under prefers-reduced-motion. Token-only theming.
21
+ */
22
+ import { type Snippet } from 'svelte';
23
+ import type { ShortcutRegistry } from '../../utils/keyboardShortcutRegistry';
24
+ import { type MediaZoomFooterContext, type MediaZoomView } from './mediaZoom';
25
+ interface Props {
26
+ /** The views of the item being judged (one or more). */
27
+ items: MediaZoomView[];
28
+ /** Current view (bindable). Seeded from the host; clamped to `items`. */
29
+ index?: number;
30
+ /** Item name shown before the view label ("Grease trap card"). */
31
+ title?: string;
32
+ /** Extra header text after the view counter, e.g. "item 3 of 12". */
33
+ position?: string;
34
+ onClose: () => void;
35
+ /** Host pages to the next item. Omit (or pass undefined at the end) to disable. */
36
+ onNextItem?: () => void;
37
+ onPrevItem?: () => void;
38
+ /** Bottom-bar slot — VerdictControl + ReviewNote live here in review hosts. */
39
+ footer?: Snippet<[MediaZoomFooterContext]>;
40
+ /** Frame-step rate. Review loops are 30fps. */
41
+ fps?: number;
42
+ speeds?: readonly number[];
43
+ /** Autoplay video (always off under prefers-reduced-motion). */
44
+ autoplay?: boolean;
45
+ muted?: boolean;
46
+ /** Show the one-line key hint under the controls. */
47
+ showHint?: boolean;
48
+ zIndex?: string | number;
49
+ /** While open, push a modal context so page shortcuts don't fire behind it. */
50
+ shortcutRegistry?: ShortcutRegistry;
51
+ shortcutContext?: string;
52
+ class?: string;
53
+ }
54
+ declare const MediaZoomLightbox: import("svelte").Component<Props, {}, "index">;
55
+ type MediaZoomLightbox = ReturnType<typeof MediaZoomLightbox>;
56
+ export default MediaZoomLightbox;