@joewinke/jatui 0.38.5 → 0.38.6

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; 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?` |
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; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). 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) |
@@ -167,6 +167,12 @@
167
167
  import { dragCancel, dragEnd, dragMove, dragStart, type DragState } from '../video/band/drag';
168
168
  import { createBandLayerCache } from '../video/band/memo';
169
169
  import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
170
+ import {
171
+ highlightRowHtml,
172
+ lineScrubTarget,
173
+ pickMatch,
174
+ transcriptWheelDecision
175
+ } from '../video/transcriptSearch';
170
176
  import {
171
177
  adMaxStep,
172
178
  adSkipTarget,
@@ -887,6 +893,18 @@
887
893
  let transcriptVisible = $state(true);
888
894
  let keysOpen = $state(false);
889
895
 
896
+ // ── Transcript search (jatui-xmtnv) ────────────────────────────────
897
+ // `/` opens a search box over the transcript pane; matches highlight in
898
+ // place, Enter jumps to the next match (Shift+Enter back), Esc closes.
899
+ // The page omnibar's own `/` yields while focus is inside the player:
900
+ // the claiming keydown's propagation is stopped, and the search box
901
+ // stops its own keydowns so typing never leaks into the player map or
902
+ // the page.
903
+ let searchOpen = $state(false);
904
+ let searchQuery = $state('');
905
+ let searchMatchIdx = $state(-1);
906
+ let searchInputEl: HTMLInputElement | undefined = $state();
907
+
890
908
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
891
909
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
892
910
 
@@ -1182,6 +1200,23 @@
1182
1200
  const sortedSegments = $derived(sortSegments(transcript));
1183
1201
  const sortedBookmarks = $derived(sortBookmarks(bookmarks));
1184
1202
 
1203
+ // ── Transcript search deriveds (jatui-xmtnv) ─────────────────────────
1204
+ /** Live only while the box is open with a non-empty query. */
1205
+ const searchActive = $derived(searchOpen && searchQuery.trim().length > 0);
1206
+ /** Matching rows in the pane's own play order — count and jump order
1207
+ * agree with what's on screen. */
1208
+ const searchMatches = $derived.by(() => {
1209
+ if (!searchActive) return [];
1210
+ const q = searchQuery.trim().toLowerCase();
1211
+ return sortedSegments.filter((s) => s.text.toLowerCase().includes(q));
1212
+ });
1213
+ /** The match the Enter-cursor is on — wears the distinct "current" mark. */
1214
+ const searchCurrentId = $derived(
1215
+ searchMatchIdx >= 0 && searchMatchIdx < searchMatches.length
1216
+ ? searchMatches[searchMatchIdx].id
1217
+ : null
1218
+ );
1219
+
1185
1220
  /** Peaks normalised to 0–1 against their own max, so 0–1, 0–99 and 0–255 inputs all work. */
1186
1221
  const peaks = $derived.by(() => {
1187
1222
  const raw = waveform?.peaks;
@@ -2809,7 +2844,11 @@
2809
2844
  return;
2810
2845
  }
2811
2846
  if (e.shiftKey) {
2812
- const delta = e.deltaY < 0 ? 5 : -5;
2847
+ // Chrome on Linux/Windows and macOS turn Shift+wheel into a
2848
+ // HORIZONTAL scroll, so deltaY is 0 and the value sits in deltaX.
2849
+ // Use the effective delta to drive the seek step direction.
2850
+ const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
2851
+ const delta = d < 0 ? 5 : -5;
2813
2852
  seekTo(currentTime + delta);
2814
2853
  flashOsd(`${delta > 0 ? '+' : ''}${delta}s · ${formatTime(currentTime + delta, useHours)}`);
2815
2854
  return;
@@ -2858,6 +2897,22 @@
2858
2897
  return;
2859
2898
  }
2860
2899
 
2900
+ // The transcript search (jatui-xmtnv) owns Escape while it's open —
2901
+ // even when focus has left its input — and re-focuses on `/`; every
2902
+ // other key waits so typing never drives the player.
2903
+ if (searchOpen) {
2904
+ if (e.key === 'Escape') {
2905
+ e.preventDefault();
2906
+ e.stopPropagation();
2907
+ closeSearch();
2908
+ } else if (e.key === '/') {
2909
+ e.preventDefault();
2910
+ e.stopPropagation(); // the omnibar yields while focus is inside the player
2911
+ searchInputEl?.focus();
2912
+ }
2913
+ return;
2914
+ }
2915
+
2861
2916
  switch (e.key) {
2862
2917
  case '?':
2863
2918
  e.preventDefault();
@@ -2968,6 +3023,16 @@
2968
3023
  flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
2969
3024
  }
2970
3025
  return;
3026
+ case '/':
3027
+ // Transcript search (jatui-xmtnv). The omnibar yields: focus is
3028
+ // inside the player, so this keydown never reaches the page's
3029
+ // own `/` handler (stopPropagation below the player root).
3030
+ if (sortedSegments.length > 0) {
3031
+ e.preventDefault();
3032
+ e.stopPropagation();
3033
+ openSearch();
3034
+ }
3035
+ return;
2971
3036
  case 'n':
2972
3037
  case 'N':
2973
3038
  if (night !== undefined) {
@@ -3106,6 +3171,88 @@
3106
3171
  seekTo(seg.startSec);
3107
3172
  }
3108
3173
 
3174
+ // ── Transcript search (jatui-xmtnv) ───────────────────────────────────
3175
+ /** `/`: open the box (showing the transcript if it was hidden) and hand
3176
+ * it focus once it mounts — the effect below lands it. */
3177
+ function openSearch() {
3178
+ if (sortedSegments.length === 0) return;
3179
+ transcriptVisible = true;
3180
+ searchOpen = true;
3181
+ searchMatchIdx = -1;
3182
+ }
3183
+ /** Esc / the × button: close, clear, and give the keys back to the map. */
3184
+ function closeSearch() {
3185
+ searchOpen = false;
3186
+ searchQuery = '';
3187
+ searchMatchIdx = -1;
3188
+ rootEl?.focus();
3189
+ }
3190
+ /** Enter (next) / Shift+Enter (previous), wrapping. With no cursor yet a
3191
+ * search opened mid-talk starts from the match at/after the playhead. */
3192
+ function jumpSearch(direction: 1 | -1) {
3193
+ const matches = searchMatches;
3194
+ if (matches.length === 0) return;
3195
+ searchMatchIdx = pickMatch(
3196
+ matches.map((m) => m.startSec),
3197
+ currentTime,
3198
+ direction,
3199
+ searchMatchIdx
3200
+ );
3201
+ seekTo(matches[searchMatchIdx].startSec);
3202
+ }
3203
+ /** The box owns its keys — never leak into the player map or the page
3204
+ * omnibar while it's open (typing `k` must not pause, `/` must not
3205
+ * re-open the omnibar). */
3206
+ function onSearchKeydown(e: KeyboardEvent) {
3207
+ e.stopPropagation();
3208
+ if (e.key === 'Enter') {
3209
+ e.preventDefault();
3210
+ jumpSearch(e.shiftKey ? -1 : 1);
3211
+ } else if (e.key === 'Escape') {
3212
+ e.preventDefault();
3213
+ closeSearch();
3214
+ }
3215
+ }
3216
+ // Focus + select the box when it opens (the input mounts with it).
3217
+ $effect(() => {
3218
+ if (searchOpen && searchInputEl) {
3219
+ searchInputEl.focus();
3220
+ searchInputEl.select();
3221
+ }
3222
+ });
3223
+
3224
+ // ── Transcript wheel scrub (jatui-xmtnv) ─────────────────────────────────
3225
+ // A plain wheel over the transcript pane scrolls it natively — exactly as
3226
+ // before (the reader's own scroll, follow-intent logic untouched —
3227
+ // jatui-ap4oz/ee2ty); preventDefault is NEVER taken for it, so the page is
3228
+ // never trapped. Shift+wheel steps PLAYBACK one line at a time (extension
3229
+ // library.js:8580 parity, matching Shift+wheel over the video = seek): the
3230
+ // pure decision in video/transcriptSearch.ts accumulates deltas so one
3231
+ // notch is one line — a mouse wheel's ~100px notches step exactly once
3232
+ // each and a trackpad's stream of small deltas never flies through the
3233
+ // transcript. Seeking makes the stepped line active, so the pane follows
3234
+ // it (the follow's own scrolls are never intent).
3235
+ let lineWheelAccum = 0;
3236
+ function onTranscriptWheel(e: WheelEvent) {
3237
+ const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
3238
+ const decision = transcriptWheelDecision(
3239
+ e.shiftKey,
3240
+ sortedSegments.length > 0,
3241
+ d,
3242
+ lineWheelAccum
3243
+ );
3244
+ if (!decision.claimed) return; // plain wheel = native pane scroll
3245
+ e.preventDefault();
3246
+ lineWheelAccum = decision.nextAccum;
3247
+ if (!decision.shouldStep) return;
3248
+ const target = lineScrubTarget(
3249
+ currentTime,
3250
+ sortedSegments.map((s) => s.startSec),
3251
+ decision.direction
3252
+ );
3253
+ if (target != null) seekTo(target);
3254
+ }
3255
+
3109
3256
  // No active line (seeked past the transcript) ⇒ nothing to jump to.
3110
3257
  $effect(() => {
3111
3258
  if (!activeSegment && followPaused) untrack(() => (followPaused = false));
@@ -3795,10 +3942,42 @@
3795
3942
  style:--vp-transcript-max={transcriptMaxHeight}
3796
3943
  style:--vp-transcript-side-max={!transcriptMaxHeight && mainHeight > 0 ? `${mainHeight}px` : undefined}
3797
3944
  >
3945
+ {#if searchOpen}
3946
+ <!-- Transcript search (jatui-xmtnv): `/` opened it. Enter jumps to
3947
+ the next match (Shift+Enter back), Esc closes; the count is
3948
+ aria-live so a screen reader hears match N of M. -->
3949
+ <div class="vp-search">
3950
+ <input
3951
+ class="vp-search-input"
3952
+ type="text"
3953
+ placeholder="Search transcript"
3954
+ aria-label="Search transcript"
3955
+ spellcheck="false"
3956
+ bind:value={searchQuery}
3957
+ bind:this={searchInputEl}
3958
+ oninput={() => (searchMatchIdx = -1)}
3959
+ onkeydown={onSearchKeydown}
3960
+ />
3961
+ <span class="vp-search-count" aria-live="polite">
3962
+ {searchMatches.length === 0
3963
+ ? (searchActive ? 'No matches' : '')
3964
+ : searchMatchIdx >= 0
3965
+ ? `${searchMatchIdx + 1} / ${searchMatches.length}`
3966
+ : `${searchMatches.length}`}
3967
+ </span>
3968
+ <button
3969
+ type="button"
3970
+ class="vp-search-close"
3971
+ aria-label="Close search"
3972
+ onclick={closeSearch}
3973
+ >×</button>
3974
+ </div>
3975
+ {/if}
3798
3976
  <div
3799
3977
  class="vp-transcript"
3800
3978
  use:transcriptPaneAction
3801
3979
  onscroll={onTranscriptScroll}
3980
+ onwheel={onTranscriptWheel}
3802
3981
  >
3803
3982
  {#if transWindow}
3804
3983
  <!-- Windowed: only the rows near the viewport exist in the DOM.
@@ -3823,7 +4002,15 @@
3823
4002
  {seg.speakerName ?? 'Unknown speaker'}
3824
4003
  </span>
3825
4004
  {/if}
3826
- <span class="vp-row-text">{seg.text}</span>
4005
+ <span class="vp-row-text">
4006
+ {#if searchActive}
4007
+ <!-- Search marks are safe HTML: highlightRowHtml escapes
4008
+ everything but its own <mark> tags (jatui-xmtnv). -->
4009
+ {@html highlightRowHtml(seg.text, searchQuery, searchCurrentId === seg.id)}
4010
+ {:else}
4011
+ {seg.text}
4012
+ {/if}
4013
+ </span>
3827
4014
  </button>
3828
4015
  {/each}
3829
4016
  <div class="vp-spacer" style="height: {transWindow.padBottom}px" aria-hidden="true"></div>
@@ -3846,7 +4033,13 @@
3846
4033
  {seg.speakerName ?? 'Unknown speaker'}
3847
4034
  </span>
3848
4035
  {/if}
3849
- <span class="vp-row-text">{seg.text}</span>
4036
+ <span class="vp-row-text">
4037
+ {#if searchActive}
4038
+ {@html highlightRowHtml(seg.text, searchQuery, searchCurrentId === seg.id)}
4039
+ {:else}
4040
+ {seg.text}
4041
+ {/if}
4042
+ </span>
3850
4043
  </button>
3851
4044
  {/each}
3852
4045
  {/if}
@@ -4909,6 +5102,79 @@
4909
5102
  }
4910
5103
  }
4911
5104
 
5105
+ /* Transcript search (jatui-xmtnv): a slim bar over the pane's top edge.
5106
+ Focus ring on the input only; marks tint with the primary token, and
5107
+ the cursor's match is filled so "which match am I on" is legible at a
5108
+ glance. */
5109
+ .vp-search {
5110
+ position: absolute;
5111
+ top: 0;
5112
+ left: 0;
5113
+ right: 0;
5114
+ z-index: 3;
5115
+ display: flex;
5116
+ align-items: center;
5117
+ gap: var(--jatui-space-sm, 4px);
5118
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-md, 6px);
5119
+ background: var(--color-base-100);
5120
+ border: var(--jatui-border-w, 1px) solid
5121
+ color-mix(in oklch, var(--color-primary) 45%, transparent);
5122
+ border-radius: var(--jatui-radius-box, 0.5rem);
5123
+ }
5124
+
5125
+ .vp-search-input {
5126
+ flex: 1 1 auto;
5127
+ min-width: 0;
5128
+ background: none;
5129
+ border: none;
5130
+ outline: none;
5131
+ color: var(--color-base-content);
5132
+ font-size: 0.8125rem;
5133
+ padding: 0;
5134
+ }
5135
+
5136
+ .vp-search-input::placeholder {
5137
+ color: color-mix(in oklch, var(--color-base-content) 45%, transparent);
5138
+ }
5139
+
5140
+ .vp-search-count {
5141
+ flex: none;
5142
+ font-family: ui-monospace, monospace;
5143
+ font-size: 0.66rem;
5144
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
5145
+ }
5146
+
5147
+ .vp-search-close {
5148
+ flex: none;
5149
+ background: none;
5150
+ border: none;
5151
+ color: var(--color-base-content);
5152
+ cursor: pointer;
5153
+ padding: 0 var(--jatui-space-sm, 4px);
5154
+ font-size: 0.9rem;
5155
+ line-height: 1;
5156
+ border-radius: var(--jatui-radius-field, 0.25rem);
5157
+ }
5158
+
5159
+ .vp-search-close:hover {
5160
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
5161
+ }
5162
+
5163
+ /* Match highlight inside a transcript row — an <mark> tag, so UA styling
5164
+ is overridden with the token surface. The cursor's match is filled with
5165
+ the primary token; the rest is a softer tint. */
5166
+ .vp-row-text :global(mark.vp-search-mark) {
5167
+ background: color-mix(in oklch, var(--color-primary) 20%, transparent);
5168
+ color: inherit;
5169
+ border-radius: 2px;
5170
+ padding: 0 1px;
5171
+ }
5172
+
5173
+ .vp-row-text :global(mark.vp-search-mark.current) {
5174
+ background: var(--color-primary);
5175
+ color: var(--color-primary-content);
5176
+ }
5177
+
4912
5178
  /* Keyboard help overlay — the `?` surface. Keycaps first, description after;
4913
5179
  token-only colours, same chrome family as the hover card. */
4914
5180
  .vp-keys-backdrop {
@@ -7,11 +7,11 @@
7
7
  * Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
8
8
  * speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
9
9
  * (t is theater mode, YouTube's key — jatui-ee2ty), ?
10
- * shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
11
- * library-app verbs, not player verbs), `/` (transcript search — the pane
12
- * has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
13
- * and `,` (settings) with the settings panel (mos-5lzns.35), each gated on
14
- * the host opting in.
10
+ * shortcuts, Home/End, and `/` transcript search (the pane has none yet).
11
+ * Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
12
+ * not player verbs). N (night cycle) landed with the night module
13
+ * (mos-5lzns.33) and `,` (settings) with the settings panel
14
+ * (mos-5lzns.35), each gated on the host opting in.
15
15
  */
16
16
  export interface PlayerKeyRow {
17
17
  /** Keycap label as shown in the help overlay — `/`-separated alternates. */
@@ -7,11 +7,11 @@
7
7
  * Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
8
8
  * speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
9
9
  * (t is theater mode, YouTube's key — jatui-ee2ty), ?
10
- * shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
11
- * library-app verbs, not player verbs), `/` (transcript search — the pane
12
- * has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
13
- * and `,` (settings) with the settings panel (mos-5lzns.35), each gated on
14
- * the host opting in.
10
+ * shortcuts, Home/End, and `/` transcript search (the pane has none yet).
11
+ * Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
12
+ * not player verbs). N (night cycle) landed with the night module
13
+ * (mos-5lzns.33) and `,` (settings) with the settings panel
14
+ * (mos-5lzns.35), each gated on the host opting in.
15
15
  */
16
16
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
17
17
  export const PLAYER_KEYS = [
@@ -34,6 +34,10 @@ export const PLAYER_KEYS = [
34
34
  // t = theater, YouTube's key; the transcript moved to Shift+T (jatui-ee2ty).
35
35
  { keys: 't', description: 'Theater mode on / off', when: 'theater' },
36
36
  { keys: 'Shift+T', description: 'Show / hide the transcript', when: 'transcript' },
37
+ { keys: '/', description: 'Search transcript', when: 'transcript' },
38
+ // Shift+wheel over the transcript pane steps PLAYBACK line by line —
39
+ // a plain wheel there scrolls the pane natively and is never claimed.
40
+ { keys: 'Shift+wheel', description: 'Over the transcript: steps line by line', when: 'transcript' },
37
41
  { keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
38
42
  { keys: 'Home / End', description: 'Jump to start / end' },
39
43
  { keys: ',', description: 'Settings', when: 'settings' },
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Transcript search + wheel line-scrub (jatui-xmtnv — extension-library
3
+ * parity, mos-telti). Pure, DOM-free helpers the component calls; no module
4
+ * here reads settings or touches the <video> element (the advisor doctrine,
5
+ * src/lib/video/README.md).
6
+ *
7
+ * Two features:
8
+ *
9
+ * 1. `/` opens a search box over the transcript pane. Matches highlight in
10
+ * place; Enter jumps to the next match (Shift+Enter the previous one,
11
+ * wrapping); Esc closes. The page omnibar's own `/` yields while focus is
12
+ * inside the player — the component stops the keydown's propagation when
13
+ * it claims the key.
14
+ * 2. The wheel over the transcript pane steps PLAYBACK one line at a time —
15
+ * not the pane's scroll. Deltas accumulate so one notch is one line: a
16
+ * mouse wheel's ~100px notches step exactly once each, and a trackpad's
17
+ * stream of small deltas never flies through the transcript.
18
+ */
19
+ /** Wheel delta that equals one line step. A mouse notch is ~100–120; a
20
+ * trackpad emits many small deltas — accumulating keeps one notch = one
21
+ * line without making trackpad scrub feel stuck. */
22
+ export declare const WHEEL_LINE_NOTCH = 50;
23
+ /** Escaping for the {@html} path: transcript text is host data, never markup. */
24
+ export declare function escapeHtml(text: string): string;
25
+ /**
26
+ * Case-insensitive, non-overlapping `[start, end)` ranges of every occurrence
27
+ * of `query` in `text`. An empty (or whitespace-only) query matches nothing.
28
+ */
29
+ export declare function searchHits(text: string, query: string): Array<{
30
+ start: number;
31
+ end: number;
32
+ }>;
33
+ /**
34
+ * HTML for one transcript row's text with `<mark>` around every match —
35
+ * safe to feed to `{@html}`: the surrounding text is escaped, only the mark
36
+ * tags are ours. `current` (the Enter-cursor's row) wears a second class so
37
+ * the player can style the match you're ON distinctly from the rest.
38
+ */
39
+ export declare function highlightRowHtml(text: string, query: string, current: boolean): string;
40
+ /**
41
+ * The segment to land on when the wheel steps playback one line. Wheel-down
42
+ * (direction 1) seeks to the NEXT line's start; wheel-up (−1) re-plays the
43
+ * line the playhead is already in when it is more than `grace` seconds past
44
+ * its start (one up-notch replays what you just heard), otherwise the
45
+ * previous line. Returns null at the transcript's edges.
46
+ */
47
+ export declare function lineScrubTarget(currentTime: number, starts: ArrayLike<number>, direction: 1 | -1, grace?: number): number | null;
48
+ /** What the transcript pane's wheel handler should do with one wheel event. */
49
+ export interface TranscriptWheelDecision {
50
+ /** True when the wheel is the shift verb — the handler must preventDefault. */
51
+ claimed: boolean;
52
+ /** True when the accumulated delta crossed a notch: step a line now. */
53
+ shouldStep: boolean;
54
+ /** Step direction: 1 = next line (wheel down), −1 = previous (wheel up). */
55
+ direction: 1 | -1;
56
+ /** The accumulator to carry to the next wheel event. */
57
+ nextAccum: number;
58
+ }
59
+ /**
60
+ * Decide one transcript-pane wheel event. A plain wheel (no shift) or a
61
+ * pane with no transcript is NEVER claimed — native scroll, accumulator
62
+ * untouched. A shift wheel accumulates deltas; crossing {@link WHEEL_LINE_NOTCH}
63
+ * spends the accumulator (back to 0) and steps one line in the sign's
64
+ * direction.
65
+ */
66
+ /**
67
+ * The effective wheel delta for a shift-modified gesture. Chrome on
68
+ * Linux/Windows and macOS turn Shift+wheel into a HORIZONTAL scroll:
69
+ * `e.deltaY` is 0 and the value sits in `e.deltaX`. Headless browsers don't
70
+ * swap, so tests only checking `deltaY` pass while a real browser never
71
+ * steps. Every shift verb must read this fallback.
72
+ */
73
+ export declare function effectiveWheelDelta(deltaY: number, deltaX: number): number;
74
+ export declare function transcriptWheelDecision(shiftKey: boolean, hasSegments: boolean, deltaY: number, accum: number): TranscriptWheelDecision;
75
+ /** Which match Enter/Shift+Enter lands on. With no cursor yet, the first
76
+ * match at/after the playhead (before it for backwards) — a search opened
77
+ * mid-talk starts where you are, not at the top. Afterwards, the cursor
78
+ * walks the match list and wraps at its ends. Returns the index, or −1 when
79
+ * there are no matches. */
80
+ export declare function pickMatch(matchStarts: ArrayLike<number>, currentTime: number, direction: 1 | -1, cursor: number): number;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Transcript search + wheel line-scrub (jatui-xmtnv — extension-library
3
+ * parity, mos-telti). Pure, DOM-free helpers the component calls; no module
4
+ * here reads settings or touches the <video> element (the advisor doctrine,
5
+ * src/lib/video/README.md).
6
+ *
7
+ * Two features:
8
+ *
9
+ * 1. `/` opens a search box over the transcript pane. Matches highlight in
10
+ * place; Enter jumps to the next match (Shift+Enter the previous one,
11
+ * wrapping); Esc closes. The page omnibar's own `/` yields while focus is
12
+ * inside the player — the component stops the keydown's propagation when
13
+ * it claims the key.
14
+ * 2. The wheel over the transcript pane steps PLAYBACK one line at a time —
15
+ * not the pane's scroll. Deltas accumulate so one notch is one line: a
16
+ * mouse wheel's ~100px notches step exactly once each, and a trackpad's
17
+ * stream of small deltas never flies through the transcript.
18
+ */
19
+ /** Wheel delta that equals one line step. A mouse notch is ~100–120; a
20
+ * trackpad emits many small deltas — accumulating keeps one notch = one
21
+ * line without making trackpad scrub feel stuck. */
22
+ export const WHEEL_LINE_NOTCH = 50;
23
+ /** Escaping for the {@html} path: transcript text is host data, never markup. */
24
+ export function escapeHtml(text) {
25
+ return text
26
+ .replaceAll('&', '&amp;')
27
+ .replaceAll('<', '&lt;')
28
+ .replaceAll('>', '&gt;')
29
+ .replaceAll('"', '&quot;')
30
+ .replaceAll("'", '&#39;');
31
+ }
32
+ /**
33
+ * Case-insensitive, non-overlapping `[start, end)` ranges of every occurrence
34
+ * of `query` in `text`. An empty (or whitespace-only) query matches nothing.
35
+ */
36
+ export function searchHits(text, query) {
37
+ const q = query.trim().toLowerCase();
38
+ if (!q)
39
+ return [];
40
+ const lower = text.toLowerCase();
41
+ const hits = [];
42
+ let from = 0;
43
+ for (;;) {
44
+ const at = lower.indexOf(q, from);
45
+ if (at < 0)
46
+ break;
47
+ hits.push({ start: at, end: at + q.length });
48
+ from = at + q.length;
49
+ }
50
+ return hits;
51
+ }
52
+ /**
53
+ * HTML for one transcript row's text with `<mark>` around every match —
54
+ * safe to feed to `{@html}`: the surrounding text is escaped, only the mark
55
+ * tags are ours. `current` (the Enter-cursor's row) wears a second class so
56
+ * the player can style the match you're ON distinctly from the rest.
57
+ */
58
+ export function highlightRowHtml(text, query, current) {
59
+ const hits = searchHits(text, query);
60
+ if (hits.length === 0)
61
+ return escapeHtml(text);
62
+ const cls = current ? 'vp-search-mark current' : 'vp-search-mark';
63
+ let out = '';
64
+ let at = 0;
65
+ for (const hit of hits) {
66
+ out += escapeHtml(text.slice(at, hit.start));
67
+ out += `<mark class="${cls}">${escapeHtml(text.slice(hit.start, hit.end))}</mark>`;
68
+ at = hit.end;
69
+ }
70
+ out += escapeHtml(text.slice(at));
71
+ return out;
72
+ }
73
+ /**
74
+ * The segment to land on when the wheel steps playback one line. Wheel-down
75
+ * (direction 1) seeks to the NEXT line's start; wheel-up (−1) re-plays the
76
+ * line the playhead is already in when it is more than `grace` seconds past
77
+ * its start (one up-notch replays what you just heard), otherwise the
78
+ * previous line. Returns null at the transcript's edges.
79
+ */
80
+ export function lineScrubTarget(currentTime, starts, direction, grace = 0.5) {
81
+ const n = starts.length;
82
+ if (n === 0)
83
+ return null;
84
+ // Index of the line the playhead is in (the last start ≤ time).
85
+ let idx = 0;
86
+ for (let i = 0; i < n; i++) {
87
+ if (starts[i] <= currentTime)
88
+ idx = i;
89
+ else
90
+ break;
91
+ }
92
+ if (direction === 1) {
93
+ return idx + 1 < n ? starts[idx + 1] : null;
94
+ }
95
+ if (currentTime - starts[idx] > grace)
96
+ return starts[idx];
97
+ return idx > 0 ? starts[idx - 1] : null;
98
+ }
99
+ /**
100
+ * Decide one transcript-pane wheel event. A plain wheel (no shift) or a
101
+ * pane with no transcript is NEVER claimed — native scroll, accumulator
102
+ * untouched. A shift wheel accumulates deltas; crossing {@link WHEEL_LINE_NOTCH}
103
+ * spends the accumulator (back to 0) and steps one line in the sign's
104
+ * direction.
105
+ */
106
+ // ── transcriptWheelDecision ───────────────────────────────────────────────
107
+ /**
108
+ * The effective wheel delta for a shift-modified gesture. Chrome on
109
+ * Linux/Windows and macOS turn Shift+wheel into a HORIZONTAL scroll:
110
+ * `e.deltaY` is 0 and the value sits in `e.deltaX`. Headless browsers don't
111
+ * swap, so tests only checking `deltaY` pass while a real browser never
112
+ * steps. Every shift verb must read this fallback.
113
+ */
114
+ export function effectiveWheelDelta(deltaY, deltaX) {
115
+ return deltaY !== 0 ? deltaY : deltaX;
116
+ }
117
+ export function transcriptWheelDecision(shiftKey, hasSegments, deltaY, accum) {
118
+ if (!shiftKey || !hasSegments) {
119
+ return { claimed: false, shouldStep: false, direction: 1, nextAccum: accum };
120
+ }
121
+ const next = accum + deltaY;
122
+ if (Math.abs(next) < WHEEL_LINE_NOTCH) {
123
+ return { claimed: true, shouldStep: false, direction: next > 0 ? 1 : -1, nextAccum: next };
124
+ }
125
+ return { claimed: true, shouldStep: true, direction: next > 0 ? 1 : -1, nextAccum: 0 };
126
+ }
127
+ /** Which match Enter/Shift+Enter lands on. With no cursor yet, the first
128
+ * match at/after the playhead (before it for backwards) — a search opened
129
+ * mid-talk starts where you are, not at the top. Afterwards, the cursor
130
+ * walks the match list and wraps at its ends. Returns the index, or −1 when
131
+ * there are no matches. */
132
+ export function pickMatch(matchStarts, currentTime, direction, cursor) {
133
+ const n = matchStarts.length;
134
+ if (n === 0)
135
+ return -1;
136
+ if (cursor < 0 || cursor >= n) {
137
+ if (direction === 1) {
138
+ for (let i = 0; i < n; i++)
139
+ if (matchStarts[i] >= currentTime)
140
+ return i;
141
+ return 0;
142
+ }
143
+ let idx = 0;
144
+ for (let i = 0; i < n; i++) {
145
+ if (matchStarts[i] <= currentTime)
146
+ idx = i;
147
+ else
148
+ break;
149
+ }
150
+ return idx;
151
+ }
152
+ return (cursor + direction + n) % n;
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.38.5",
3
+ "version": "0.38.6",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",