@joewinke/jatui 0.38.4 → 0.38.5

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
@@ -119,7 +119,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
119
119
  | `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
120
120
  | `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?` |
121
121
  | `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?` |
122
- | `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, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
122
+ | `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, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
123
123
  | `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?` |
124
124
  | `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?` |
125
125
  | `ListenControl` | "Listen to this page" — a compact pill that expands into a TWO-ROW playback bar over the host's PER-SENTENCE audio (TTS): row 1 = play/pause · chapter chip + current sentence (full remaining width) · ONE settings pill ("1.25× · Sarah" → speed 0.75–2× + voice menu) · close; row 2 = prev/next sentence and −15s/+15s · a document-wide progress bar with chapter ticks (a hatched tail marks the not-yet-synthesized part), time, close. The host supplies an async `source` (sentences/paragraphs with ids → audio URLs, streamed page by page so playback starts on the first chunk while the rest synthesizes; pages merge by id so late URLs fill in) and gets `onSegmentChange(id)` at every boundary for highlight + scroll-follow. Media Session (lock-screen/headset controls, one document-wide position), keyboard scoped to the player (Space/K, ←/→ sentences, Shift+←/→ ±15s, [ / ] speed, Escape), speed+voice persisted in localStorage, `bind:api` for hosts wiring their own global keys, reduced-motion aware, token-only theming, never overflows its host (border-box, `max-width: 100%`, `min-width: 0` chain), fixed sticky mini-bar (same two rows) on narrow screens. Internals in `src/lib/listen/` (one timeline across per-sentence files — `buildTimeline` offsets; see `video/` for the doctrine). Demo synthesizes real WAVs locally. | `source`, `voices?`, `voiceId?` (bindable), `chapters?`, `title?`, `triggerLabel?`, `open?` (bindable), `preload?`, `estimateSec?`, `storageKey?`, `onSegmentChange?`, `onOpenChange?`, `api?` (bindable), `class?`, `children?` (custom trigger) |
@@ -809,6 +809,7 @@
809
809
  if (!v) return;
810
810
  v.muted = !v.muted;
811
811
  if (!v.muted && v.volume === 0) v.volume = 0.5;
812
+ showVolumeOsd(v.volume, v.muted);
812
813
  }
813
814
  function onVolumeInput(e: Event) {
814
815
  const v = videoEl;
@@ -816,6 +817,7 @@
816
817
  const next = Number((e.currentTarget as HTMLInputElement).value);
817
818
  v.volume = Math.min(1, Math.max(0, next));
818
819
  v.muted = next === 0;
820
+ showVolumeOsd(v.volume, v.muted);
819
821
  }
820
822
  function toggleFullscreen() {
821
823
  if (typeof document === 'undefined') return;
@@ -858,6 +860,16 @@
858
860
  let osd = $state(''); // transient wheel/keyboard readout (volume / seek / speed)
859
861
  let osdOn = $state(false);
860
862
 
863
+ // ── Volume OSD (jatui-pu698) ────────────────────────────────────────────
864
+ // Wheel/keyboard volume feedback: a speaker glyph + horizontal level bar
865
+ // + % value (or "Muted" at 0), floated over the stage. Distinct from the
866
+ // tiny corner readout: a volume change is a physical gesture that deserves
867
+ // to be SEEN, not just a number blink.
868
+ let volOsdOn = $state(false);
869
+ let volOsdVolume = $state(0);
870
+ let volOsdMuted = $state(false);
871
+ let volOsdTimer: ReturnType<typeof setTimeout> | null = null;
872
+
861
873
  // ── Speaker filter state ────────────────────────────────────────────────
862
874
  let soloKey = $state(ALL_KEY);
863
875
  let mutedKeys = $state(new Set<string>());
@@ -1382,6 +1394,24 @@
1382
1394
  zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
1383
1395
  }
1384
1396
 
1397
+ // ── Volume OSD (jatui-pu698) ────────────────────────────────────────────
1398
+ // Big, legible volume indicator: speaker glyph + level bar + % ("Muted" at
1399
+ // 0). Replaces the tiny corner badge for wheel/keyboard volume gestures.
1400
+ // Shares `osdTimer` with the plain OSD so only ONE readout floats at a
1401
+ // time, and any pending plain flash is superseded (not queued behind).
1402
+ function showVolumeOsd(volume: number, muted: boolean) {
1403
+ if (volOsdTimer) clearTimeout(volOsdTimer);
1404
+ if (osdTimer) {
1405
+ clearTimeout(osdTimer);
1406
+ osdTimer = null;
1407
+ }
1408
+ osdOn = false; // only one readout at a time
1409
+ volOsdVolume = volume;
1410
+ volOsdMuted = muted;
1411
+ volOsdOn = true;
1412
+ volOsdTimer = setTimeout(() => (volOsdOn = false), 800);
1413
+ }
1414
+
1385
1415
  function flashOsd(text: string, ms = 900) {
1386
1416
  if (osdTimer) clearTimeout(osdTimer);
1387
1417
  if (!text || ms <= 0) {
@@ -2787,7 +2817,7 @@
2787
2817
  const next = Math.min(1, Math.max(0, videoEl.volume - Math.sign(e.deltaY) * 0.05));
2788
2818
  videoEl.volume = next;
2789
2819
  if (next > 0) videoEl.muted = false;
2790
- flashOsd(`${Math.round(next * 100)}%`);
2820
+ showVolumeOsd(next, next === 0);
2791
2821
  }
2792
2822
 
2793
2823
  /**
@@ -2853,15 +2883,15 @@
2853
2883
  case 'ArrowUp':
2854
2884
  v.volume = Math.min(1, v.volume + 0.05);
2855
2885
  if (v.volume > 0) v.muted = false;
2856
- flashOsd(`${Math.round(v.volume * 100)}%`);
2886
+ showVolumeOsd(v.volume, v.muted);
2857
2887
  break;
2858
2888
  case 'ArrowDown':
2859
2889
  v.volume = Math.max(0, v.volume - 0.05);
2860
- flashOsd(`${Math.round(v.volume * 100)}%`);
2890
+ showVolumeOsd(v.volume, v.muted);
2861
2891
  break;
2862
2892
  case 'm':
2863
2893
  v.muted = !v.muted;
2864
- flashOsd(v.muted ? 'Muted' : `${Math.round(v.volume * 100)}%`);
2894
+ showVolumeOsd(v.volume, v.muted);
2865
2895
  break;
2866
2896
  case '-':
2867
2897
  case '_':
@@ -3135,6 +3165,7 @@
3135
3165
  class:vp-rail-side={railOn}
3136
3166
  class:vp-one-axis={nativeBarHidden}
3137
3167
  class:vp-band-transport={bandTransport}
3168
+ class:vp-vol-flash={volOsdOn}
3138
3169
  class:vp-audio={isAudio}
3139
3170
  class:vp-night-capable={nightPresent}
3140
3171
  class:vp-night-surround={surroundMix > 0.001}
@@ -3255,10 +3286,41 @@
3255
3286
  <div class="vp-wind-veil" aria-hidden="true"></div>
3256
3287
  {/if}
3257
3288
  <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
3258
- </div>
3259
- </div>
3289
+ <!-- Volume OSD (jatui-pu698): speaker glyph + level bar + %, centred
3290
+ near the top of the stage so a wheel/keyboard volume gesture is
3291
+ SEEN, not just a corner number blink. aria-hidden: the volume
3292
+ slider itself already reports the value to AT. -->
3293
+ <div class="vp-volume-osd" class:on={volOsdOn} aria-hidden="true">
3294
+ <svg
3295
+ class="vp-vol-glyph"
3296
+ viewBox="0 0 24 24"
3297
+ width="24"
3298
+ height="24"
3299
+ fill="none"
3300
+ stroke="currentColor"
3301
+ stroke-width="1.8"
3302
+ stroke-linecap="round"
3303
+ stroke-linejoin="round"
3304
+ >
3305
+ <path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
3306
+ {#if volOsdMuted || volOsdVolume === 0}
3307
+ <path d="M16 9.5l5 5M21 9.5l-5 5" />
3308
+ {:else}
3309
+ <path d="M15.5 9a4 4 0 0 1 0 6" />
3310
+ {#if volOsdVolume > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
3311
+ {/if}
3312
+ </svg>
3313
+ <span class="vp-vol-bar">
3314
+ <span class="vp-vol-fill" style="width: {Math.round(volOsdVolume * 100)}%"></span>
3315
+ </span>
3316
+ <span class="vp-vol-text">
3317
+ {volOsdMuted || volOsdVolume === 0 ? 'Muted' : `${Math.round(volOsdVolume * 100)}%`}
3318
+ </span>
3319
+ </div>
3320
+ </div>
3321
+ </div>
3260
3322
 
3261
- <!-- The band: one time axis for the envelope, chapters, speakers and
3323
+ <!-- The band: one time axis for the envelope, chapters, speakers and
3262
3324
  bookmarks. Its height is reserved from the first frame, so data
3263
3325
  arriving late fades into already-owned space. -->
3264
3326
  <div
@@ -3349,7 +3411,34 @@
3349
3411
  {#if isAudio}
3350
3412
  <!-- No stage to float the readout over: it rides the band. -->
3351
3413
  <div class="vp-osd vp-osd-band" class:on={osdOn} aria-hidden="true">{osd}</div>
3352
- {/if}
3414
+ <div class="vp-volume-osd vp-volume-osd-band" class:on={volOsdOn} aria-hidden="true">
3415
+ <svg
3416
+ class="vp-vol-glyph"
3417
+ viewBox="0 0 24 24"
3418
+ width="24"
3419
+ height="24"
3420
+ fill="none"
3421
+ stroke="currentColor"
3422
+ stroke-width="1.8"
3423
+ stroke-linecap="round"
3424
+ stroke-linejoin="round"
3425
+ >
3426
+ <path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
3427
+ {#if volOsdMuted || volOsdVolume === 0}
3428
+ <path d="M16 9.5l5 5M21 9.5l-5 5" />
3429
+ {:else}
3430
+ <path d="M15.5 9a4 4 0 0 1 0 6" />
3431
+ {#if volOsdVolume > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
3432
+ {/if}
3433
+ </svg>
3434
+ <span class="vp-vol-bar">
3435
+ <span class="vp-vol-fill" style="width: {Math.round(volOsdVolume * 100)}%"></span>
3436
+ </span>
3437
+ <span class="vp-vol-text">
3438
+ {volOsdMuted || volOsdVolume === 0 ? 'Muted' : `${Math.round(volOsdVolume * 100)}%`}
3439
+ </span>
3440
+ </div>
3441
+ {/if}
3353
3442
 
3354
3443
  {#if hover}
3355
3444
  <div
@@ -4146,6 +4235,64 @@
4146
4235
  opacity: 1;
4147
4236
  }
4148
4237
 
4238
+ /* ── Volume OSD (jatui-pu698) ───────────────────────────────────────── */
4239
+ /* Wheel/keyboard volume feedback: speaker glyph + level bar + %, centred
4240
+ near the top of the stage so a gesture is SEEN, not just a corner number
4241
+ blink. Token surface with backdrop so it is legible on any frame. The
4242
+ flash is purely opacity — no slide — so it is reduced-motion safe. */
4243
+ .vp-volume-osd {
4244
+ position: absolute;
4245
+ top: 10%;
4246
+ left: 50%;
4247
+ transform: translateX(-50%);
4248
+ display: flex;
4249
+ align-items: center;
4250
+ gap: 10px;
4251
+ padding: 10px 16px;
4252
+ border-radius: 999px;
4253
+ background: color-mix(in oklch, var(--color-base-100) 85%, transparent);
4254
+ backdrop-filter: blur(8px);
4255
+ color: var(--color-base-content);
4256
+ font-size: 0.9rem;
4257
+ font-weight: 600;
4258
+ font-variant-numeric: tabular-nums;
4259
+ pointer-events: none;
4260
+ opacity: 0;
4261
+ transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
4262
+ z-index: 10;
4263
+ box-shadow: 0 4px 16px color-mix(in oklch, black 20%, transparent);
4264
+ }
4265
+
4266
+ .vp-volume-osd.on {
4267
+ opacity: 1;
4268
+ }
4269
+
4270
+ .vp-vol-glyph {
4271
+ flex: none;
4272
+ opacity: 0.9;
4273
+ }
4274
+
4275
+ .vp-vol-bar {
4276
+ display: block;
4277
+ width: 80px;
4278
+ height: 4px;
4279
+ border-radius: 999px;
4280
+ background: color-mix(in oklch, var(--color-base-content) 20%, transparent);
4281
+ overflow: hidden;
4282
+ }
4283
+
4284
+ .vp-vol-fill {
4285
+ display: block;
4286
+ height: 100%;
4287
+ background: var(--color-primary);
4288
+ border-radius: 999px;
4289
+ transition: width 150ms ease-out;
4290
+ }
4291
+
4292
+ .vp-vol-text {
4293
+ min-width: 3.5rem;
4294
+ }
4295
+
4149
4296
  /* The band owns its height from the first frame so a waveform or speaker
4150
4297
  turns arriving minutes in fade into space that is already theirs. */
4151
4298
  .vp-band {
@@ -4162,6 +4309,17 @@
4162
4309
  opacity: 1;
4163
4310
  }
4164
4311
 
4312
+ /* Volume flash (jatui-pu698): reveal the transport and its volume slider
4313
+ on a wheel/keyboard volume change. */
4314
+ .vp-vol-flash .vp-transport {
4315
+ opacity: 1;
4316
+ visibility: visible;
4317
+ }
4318
+
4319
+ .vp-vol-flash .vp-volume {
4320
+ opacity: 1;
4321
+ }
4322
+
4165
4323
  /* Audio: no picture, so no stage at all. The element still plays. */
4166
4324
  .vp-stage-audio {
4167
4325
  display: none;
@@ -4828,6 +4986,7 @@
4828
4986
  @media (prefers-reduced-motion: reduce) {
4829
4987
  .vp-band,
4830
4988
  .vp-osd,
4989
+ .vp-volume-osd,
4831
4990
  .vp-zoom,
4832
4991
  .vp-lane-span,
4833
4992
  .vp-chip,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.38.4",
3
+ "version": "0.38.5",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",