@joewinke/jatui 0.38.2 → 0.38.3

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, `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, ? 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'`), `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, `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, ? 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, a reader scroll pauses it behind a "Jump to current" pill; a hovered chapter lights its whole span on the band), `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) |
@@ -86,6 +86,8 @@
86
86
  import { listNav } from '../actions/listNav';
87
87
  // mos-777hj — the chapter rail's pure state (ends, played checks, progress).
88
88
  import { railChapters, activeRailIndex, type RailChapter } from '../video/chapterRail';
89
+ import { scrollRowIntoPane, watchReaderScroll } from '../video/transcriptFollow';
90
+ import JumpToLatest from './basic/JumpToLatest.svelte';
89
91
  import type {
90
92
  VideoBookmark,
91
93
  VideoChapter,
@@ -272,7 +274,7 @@
272
274
  talkTime = [],
273
275
  startAt = null,
274
276
  transcriptPosition = 'below',
275
- transcriptMaxHeight = '18rem',
277
+ transcriptMaxHeight,
276
278
  /**
277
279
  * CORS mode for the <video> element — passed straight through as the
278
280
  * `crossorigin` attribute. `'anonymous'` asks the server for a
@@ -431,6 +433,11 @@
431
433
  /** Seek here once metadata lands (a queued seek never fires on a later source). */
432
434
  startAt?: number | null;
433
435
  transcriptPosition?: 'below' | 'side';
436
+ /**
437
+ * CSS length bounding the transcript pane's height — it scrolls inside
438
+ * this, never the page. Default: `min(40vh, 28rem)` below the player;
439
+ * beside it (`transcriptPosition: 'side'`), the player column's height.
440
+ */
434
441
  transcriptMaxHeight?: string;
435
442
  crossorigin?: 'anonymous' | 'use-credentials' | null;
436
443
  syncUrlTime?: boolean;
@@ -799,6 +806,10 @@
799
806
  let viewStart = $state(0);
800
807
  let viewEnd = $state(0); // 0 until a duration is known
801
808
  let hoverChapter = $state(-1);
809
+ /** A chapter-rail row under the pointer or keyboard focus (-1 = none). */
810
+ let railHoverChapter = $state(-1);
811
+ /** The band canvas holds KEYBOARD focus (:focus-visible) — light the chapter under the playhead. */
812
+ let bandKeyFocus = $state(false);
802
813
  let hover: {
803
814
  x: number;
804
815
  title: string;
@@ -848,6 +859,11 @@
848
859
  });
849
860
 
850
861
  let transcriptEl: HTMLDivElement | null = null;
862
+ /** The reader scrolled the pane themselves — auto-follow waits for "Jump to current". */
863
+ let followPaused = $state(false);
864
+ /** Height of the main column (stage + band), for a side transcript's bound. */
865
+ let mainEl: HTMLDivElement | undefined = $state();
866
+ let mainHeight = $state(0);
851
867
  let vpScrollTop = $state(0);
852
868
  let vpHeight = $state(0);
853
869
  let heightsVersion = $state(0);
@@ -1041,9 +1057,13 @@
1041
1057
  scheduleRowMeasure();
1042
1058
  });
1043
1059
  ro.observe(el);
1060
+ const unwatch = watchReaderScroll(el, () => {
1061
+ if (activeSegment) followPaused = true;
1062
+ });
1044
1063
  return {
1045
1064
  destroy() {
1046
1065
  ro.disconnect();
1066
+ unwatch();
1047
1067
  transcriptEl = null;
1048
1068
  }
1049
1069
  };
@@ -1092,10 +1112,9 @@
1092
1112
  if (!railOn || railActive < 0) return;
1093
1113
  const row = chapterRailEl?.querySelector<HTMLElement>('.vp-chapter-row.active');
1094
1114
  if (!row) return;
1095
- row.scrollIntoView({
1096
- block: 'nearest',
1097
- behavior: reduceMotion ? 'auto' : 'smooth'
1098
- });
1115
+ // The rail's own overflow only — scrollIntoView would also scroll the
1116
+ // page whenever the rail sits partly off-screen (jatui-ap4oz).
1117
+ if (chapterRailEl) scrollRowIntoPane(chapterRailEl, row, !reduceMotion);
1099
1118
  });
1100
1119
  const sortedTurns = $derived(sortTurns(speakers));
1101
1120
  const sortedSegments = $derived(sortSegments(transcript));
@@ -1168,6 +1187,21 @@
1168
1187
  sortedSegments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
1169
1188
  );
1170
1189
 
1190
+ /**
1191
+ * The chapter whose span the band lights (jatui-ap4oz): the one under the
1192
+ * pointer, else a hovered/focused rail row, else — while the band has
1193
+ * keyboard focus — the chapter under the playhead.
1194
+ */
1195
+ const litChapter = $derived(
1196
+ hoverChapter >= 0
1197
+ ? hoverChapter
1198
+ : railHoverChapter >= 0
1199
+ ? railHoverChapter
1200
+ : bandKeyFocus
1201
+ ? chapterAt(currentTime, sortedChapters)
1202
+ : -1
1203
+ );
1204
+
1171
1205
  /** The zoom window, as the band module sees it (plain data — band.ts is DOM-free). */
1172
1206
  const bandWindow = $derived<BandWindow>({ start: viewStart, end: viewEnd });
1173
1207
 
@@ -1187,7 +1221,7 @@
1187
1221
  soloKey,
1188
1222
  mutedKeys,
1189
1223
  window: bandWindow,
1190
- hoverChapter,
1224
+ hoverChapter: litChapter,
1191
1225
  luma: lumaSeries,
1192
1226
  comments: socialMarkers.comments,
1193
1227
  likes: socialMarkers.likes,
@@ -2884,28 +2918,64 @@
2884
2918
  });
2885
2919
 
2886
2920
  // Auto-scroll the active transcript row into view as playback advances.
2921
+ // Only the PANE scrolls (jatui-ap4oz): scrollIntoView also scrolled the
2922
+ // window, pushing the video out of frame on load and on every new line.
2887
2923
  // Windowed panes may not have the row rendered: jump to its (estimated)
2888
2924
  // offset first, then a follow-up effect lands exactly on the real row once
2889
- // the window renders it.
2925
+ // the window renders it. A reader who scrolled the pane pauses this until
2926
+ // they press "Jump to current" (or click a line).
2927
+ function followSegment(seg: VideoTranscriptSegment) {
2928
+ const pane = transcriptEl;
2929
+ if (!pane) return;
2930
+ const node = rowRefs[seg.id];
2931
+ if (node) {
2932
+ scrollRowIntoPane(pane, node, !reduceMotion);
2933
+ return;
2934
+ }
2935
+ if (!transVirtual) return;
2936
+ const idx = segIndexById.get(seg.id);
2937
+ if (idx == null) return;
2938
+ const top = ensureOffsets()[idx] ?? 0;
2939
+ // Row top (in content coords) = pane padding + offset; land it ⅓ down.
2940
+ pane.scrollTop = Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3);
2941
+ pendingAutoScrollId = seg.id;
2942
+ }
2943
+
2890
2944
  $effect(() => {
2891
2945
  const seg = activeSegment;
2892
2946
  if (!seg) return;
2893
2947
  untrack(() => {
2894
- const node = rowRefs[seg.id];
2895
- if (node) {
2896
- node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2897
- return;
2898
- }
2899
- if (!transVirtual || !transcriptEl) return;
2900
- const idx = segIndexById.get(seg.id);
2901
- if (idx == null) return;
2902
- const top = ensureOffsets()[idx] ?? 0;
2903
- // Row top (in content coords) = pane padding + offset; land it ⅓ down.
2904
- transcriptEl.scrollTop = Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3);
2905
- pendingAutoScrollId = seg.id;
2948
+ if (!followPaused) followSegment(seg);
2906
2949
  });
2907
2950
  });
2908
2951
 
2952
+ function jumpToCurrent() {
2953
+ followPaused = false;
2954
+ if (activeSegment) followSegment(activeSegment);
2955
+ }
2956
+
2957
+ /** Clicking a line is choosing where to be: seek there and follow again. */
2958
+ function onRowClick(seg: VideoTranscriptSegment) {
2959
+ followPaused = false;
2960
+ seekTo(seg.startSec);
2961
+ }
2962
+
2963
+ // No active line (seeked past the transcript) ⇒ nothing to jump to.
2964
+ $effect(() => {
2965
+ if (!activeSegment && followPaused) untrack(() => (followPaused = false));
2966
+ });
2967
+
2968
+ // A side transcript is bounded by the player column's height, so the
2969
+ // pane shrinks to fit beside the video instead of growing past it.
2970
+ $effect(() => {
2971
+ const el = mainEl;
2972
+ if (!el || typeof ResizeObserver === 'undefined') return;
2973
+ const ro = new ResizeObserver(() => (mainHeight = el.offsetHeight));
2974
+ ro.observe(el);
2975
+ mainHeight = el.offsetHeight;
2976
+ return () => ro.disconnect();
2977
+ });
2978
+
2909
2979
  // Exact landing after a windowed jump: the row is now rendered — snap to it.
2910
2980
  $effect(() => {
2911
2981
  void renderedSegments;
@@ -2915,7 +2985,7 @@
2915
2985
  const node = rowRefs[id];
2916
2986
  if (!node) return;
2917
2987
  pendingAutoScrollId = null;
2918
- node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2988
+ if (transcriptEl && !followPaused) scrollRowIntoPane(transcriptEl, node, !reduceMotion);
2919
2989
  });
2920
2990
  });
2921
2991
 
@@ -2961,7 +3031,7 @@
2961
3031
  onclickcapture={maybeAskIdle}
2962
3032
  onkeydowncapture={maybeAskIdle}
2963
3033
  >
2964
- <div class="vp-main">
3034
+ <div class="vp-main" bind:this={mainEl}>
2965
3035
  <div
2966
3036
  class="vp-stage-wrap"
2967
3037
  class:vp-stage-audio={isAudio}
@@ -3118,6 +3188,8 @@
3118
3188
  ondblclick={onBandDblClick}
3119
3189
  oncontextmenu={onBandContextMenu}
3120
3190
  onkeydown={onBandKeyDown}
3191
+ onfocus={(e) => (bandKeyFocus = (e.currentTarget as HTMLElement).matches(':focus-visible'))}
3192
+ onblur={() => (bandKeyFocus = false)}
3121
3193
  ></canvas>
3122
3194
  <!-- Polite announcements for comment jumps/clicks — the dots are
3123
3195
  canvas pixels, so without this a comment is invisible to AT. -->
@@ -3441,6 +3513,10 @@
3441
3513
  data-chapter-index={i}
3442
3514
  aria-current={railActive === i ? 'true' : undefined}
3443
3515
  onclick={() => chapterGo(i)}
3516
+ onmouseenter={() => (railHoverChapter = i)}
3517
+ onmouseleave={() => (railHoverChapter = -1)}
3518
+ onfocus={() => (railHoverChapter = i)}
3519
+ onblur={() => (railHoverChapter = -1)}
3444
3520
  >
3445
3521
  <span class="vp-chapter-head">
3446
3522
  {#if ch.status}
@@ -3485,9 +3561,17 @@
3485
3561
  {/if}
3486
3562
 
3487
3563
  {#if sortedSegments.length > 0 && transcriptVisible}
3564
+ <!-- Height-bounded (jatui-ap4oz): the pane scrolls itself and never
3565
+ the page, so the video stays in frame. Below the player the bound
3566
+ is `transcriptMaxHeight` (default min(40vh, 28rem)); beside it, the
3567
+ player column's height. -->
3568
+ <div
3569
+ class="vp-transcript-wrap"
3570
+ style:--vp-transcript-max={transcriptMaxHeight}
3571
+ style:--vp-transcript-side-max={!transcriptMaxHeight && mainHeight > 0 ? `${mainHeight}px` : undefined}
3572
+ >
3488
3573
  <div
3489
3574
  class="vp-transcript"
3490
- style="max-height: {transcriptMaxHeight}"
3491
3575
  use:transcriptPaneAction
3492
3576
  onscroll={onTranscriptScroll}
3493
3577
  >
@@ -3506,7 +3590,7 @@
3506
3590
  ? highlightKey !== speakerKeyOf(seg)
3507
3591
  : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
3508
3592
  use:transcriptRowAction={seg}
3509
- onclick={() => seekTo(seg.startSec)}
3593
+ onclick={() => onRowClick(seg)}
3510
3594
  >
3511
3595
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
3512
3596
  {#if multiSpeaker}
@@ -3529,7 +3613,7 @@
3529
3613
  ? highlightKey !== speakerKeyOf(seg)
3530
3614
  : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
3531
3615
  use:transcriptRowAction={seg}
3532
- onclick={() => seekTo(seg.startSec)}
3616
+ onclick={() => onRowClick(seg)}
3533
3617
  >
3534
3618
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
3535
3619
  {#if multiSpeaker}
@@ -3542,6 +3626,12 @@
3542
3626
  {/each}
3543
3627
  {/if}
3544
3628
  </div>
3629
+ <JumpToLatest
3630
+ visible={followPaused && activeSegment != null}
3631
+ label="Jump to current"
3632
+ onclick={jumpToCurrent}
3633
+ />
3634
+ </div>
3545
3635
  {/if}
3546
3636
 
3547
3637
  {#if keysOpen}
@@ -3614,6 +3704,11 @@
3614
3704
 
3615
3705
  /* Side layout: transcript beside the video once there's room for both. */
3616
3706
  @media (min-width: 64rem) {
3707
+ /* Beside the player: no taller than the player column. */
3708
+ .vp-root.vp-side .vp-transcript {
3709
+ max-height: var(--vp-transcript-max, var(--vp-transcript-side-max, min(40vh, 28rem)));
3710
+ }
3711
+
3617
3712
  .vp-root.vp-side {
3618
3713
  display: grid;
3619
3714
  grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr);
@@ -3847,7 +3942,7 @@
3847
3942
  grid-area: below;
3848
3943
  }
3849
3944
 
3850
- .vp-root.vp-rail-side > .vp-transcript {
3945
+ .vp-root.vp-rail-side > .vp-transcript-wrap {
3851
3946
  grid-area: transcript;
3852
3947
  }
3853
3948
 
@@ -4409,8 +4504,21 @@
4409
4504
  background: color-mix(in oklch, var(--color-warning) 28%, transparent);
4410
4505
  }
4411
4506
 
4412
- /* Transcript pane */
4507
+ /* Transcript pane. The wrap positions the "Jump to current" pill over
4508
+ the pane and carries the height bound (jatui-ap4oz). */
4509
+ .vp-transcript-wrap {
4510
+ position: relative;
4511
+ display: flex;
4512
+ flex-direction: column;
4513
+ min-width: 0;
4514
+ min-height: 0;
4515
+ }
4516
+
4413
4517
  .vp-transcript {
4518
+ /* The bound is the pane's visible box, padding and border included. */
4519
+ box-sizing: border-box;
4520
+ max-height: var(--vp-transcript-max, min(40vh, 28rem));
4521
+ overscroll-behavior: contain;
4414
4522
  display: flex;
4415
4523
  flex-direction: column;
4416
4524
  gap: 1px;
@@ -163,6 +163,11 @@ type $$ComponentProps = {
163
163
  /** Seek here once metadata lands (a queued seek never fires on a later source). */
164
164
  startAt?: number | null;
165
165
  transcriptPosition?: 'below' | 'side';
166
+ /**
167
+ * CSS length bounding the transcript pane's height — it scrolls inside
168
+ * this, never the page. Default: `min(40vh, 28rem)` below the player;
169
+ * beside it (`transcriptPosition: 'side'`), the player column's height.
170
+ */
166
171
  transcriptMaxHeight?: string;
167
172
  crossorigin?: 'anonymous' | 'use-credentials' | null;
168
173
  syncUrlTime?: boolean;
@@ -67,6 +67,7 @@ export interface BandPaintState {
67
67
  soloKey: string;
68
68
  mutedKeys: Set<string>;
69
69
  window: BandWindow;
70
+ /** Chapter whose whole span is lit (hover / keyboard focus); -1 = none. */
70
71
  hoverChapter: number;
71
72
  /** Normalised light track (band/luma.ts); null ⇒ no ribbon. */
72
73
  luma?: LumaSeries | null;
@@ -111,6 +112,17 @@ export declare function prepareCanvas(canvas: HTMLCanvasElement): CanvasRenderin
111
112
  * the playhead. `W`/`H` are CSS pixels of the canvas box.
112
113
  */
113
114
  export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: number, s: BandPaintState): void;
115
+ /** Height of the accent rule along the top of a lit chapter span, px. */
116
+ export declare const LIT_RULE_H = 2;
117
+ /**
118
+ * Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
119
+ * start (or the end of the video) — through the zoom window, clipped to the
120
+ * canvas. Null when there is no such chapter or it is entirely off-screen.
121
+ */
122
+ export declare function chapterSpanPx(chapters: VideoChapter[], index: number, w: BandWindow, duration: number, width: number): {
123
+ x0: number;
124
+ x1: number;
125
+ } | null;
114
126
  /** Turn → BandTurn for the paint state (resolved key, ad flag). */
115
127
  export declare function toBandTurns(turns: VideoSpeakerTurn[]): BandTurn[];
116
128
  export { ALL_KEY };
@@ -161,6 +161,19 @@ export function paintBand(ctx, W, H, s) {
161
161
  }
162
162
  ctx.globalAlpha = 1;
163
163
  }
164
+ // The lit chapter (jatui-ap4oz): a wash over its whole span plus an
165
+ // accent rule along the top edge, under every other layer so the
166
+ // envelope, flags and playhead stay legible on top. Static — nothing
167
+ // animates, so it is reduced-motion safe by construction.
168
+ const lit = chapterSpanPx(s.chapters, s.hoverChapter, s.window, dur, W);
169
+ if (lit) {
170
+ ctx.fillStyle = accent;
171
+ ctx.globalAlpha = 0.14;
172
+ ctx.fillRect(lit.x0, 0, lit.x1 - lit.x0, H);
173
+ ctx.globalAlpha = 0.9;
174
+ ctx.fillRect(lit.x0, 0, lit.x1 - lit.x0, LIT_RULE_H);
175
+ ctx.globalAlpha = 1;
176
+ }
164
177
  const p = s.peaks;
165
178
  const withWave = !!p;
166
179
  const n = p?.length ?? 0;
@@ -310,6 +323,22 @@ export function paintBand(ctx, W, H, s) {
310
323
  ctx.fillRect(playheadX(s.currentTime, w, dur, W) - PLAYHEAD_W / 2, 0, PLAYHEAD_W, H);
311
324
  ctx.globalAlpha = 1;
312
325
  }
326
+ /** Height of the accent rule along the top of a lit chapter span, px. */
327
+ export const LIT_RULE_H = 2;
328
+ /**
329
+ * Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
330
+ * start (or the end of the video) — through the zoom window, clipped to the
331
+ * canvas. Null when there is no such chapter or it is entirely off-screen.
332
+ */
333
+ export function chapterSpanPx(chapters, index, w, duration, width) {
334
+ const ch = chapters[index];
335
+ if (!ch || duration <= 0 || width <= 0)
336
+ return null;
337
+ const end = chapters[index + 1]?.startSec ?? duration;
338
+ const x0 = Math.max(0, secToFrac(w, duration, ch.startSec) * width);
339
+ const x1 = Math.min(width, secToFrac(w, duration, end) * width);
340
+ return x1 > x0 ? { x0, x1 } : null;
341
+ }
313
342
  /** Turn → BandTurn for the paint state (resolved key, ad flag). */
314
343
  export function toBandTurns(turns) {
315
344
  return turns.map((t) => ({
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Transcript follow (jatui-ap4oz) — keep the active line in view by scrolling
3
+ * the transcript pane ONLY, and stop following the moment the reader scrolls
4
+ * the pane themselves.
5
+ *
6
+ * Why not `scrollIntoView`: it scrolls every scrollable ancestor up to the
7
+ * window, so a pane below the video dragged the whole page and pushed the
8
+ * video out of frame on load and on every new line. Setting the pane's own
9
+ * `scrollTop` cannot move anything else.
10
+ *
11
+ * Reader intent follows createStickToBottom's rules (actions/stickToBottom):
12
+ * wheel, touch drag, scroll keys and a scrollbar grab inside the pane are
13
+ * "the reader is reading", and follow pauses until they ask for it back
14
+ * (the "Jump to current" pill, or clicking a line). Plain `scroll` events are
15
+ * never intent — the follow's own scrolls and the windowing's height
16
+ * corrections fire them too.
17
+ */
18
+ /**
19
+ * Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
20
+ * coordinates) is fully visible, with `margin` px of breathing room — or null
21
+ * when it already is. "Nearest" semantics: move the least distance.
22
+ */
23
+ export declare function nearestScrollTop(rowTop: number, rowBottom: number, scrollTop: number, viewHeight: number, margin?: number): number | null;
24
+ /**
25
+ * Scroll `pane` (and nothing else) so `row` is visible. Returns true when it
26
+ * moved. `smooth` is the caller's call — pass false under reduced motion.
27
+ */
28
+ export declare function scrollRowIntoPane(pane: HTMLElement, row: HTMLElement, smooth: boolean, margin?: number): boolean;
29
+ /**
30
+ * Listen for reader scroll intent on `pane`; `onIntent` fires on each one.
31
+ */
32
+ export declare function watchReaderScroll(pane: HTMLElement, onIntent: () => void): () => void;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Transcript follow (jatui-ap4oz) — keep the active line in view by scrolling
3
+ * the transcript pane ONLY, and stop following the moment the reader scrolls
4
+ * the pane themselves.
5
+ *
6
+ * Why not `scrollIntoView`: it scrolls every scrollable ancestor up to the
7
+ * window, so a pane below the video dragged the whole page and pushed the
8
+ * video out of frame on load and on every new line. Setting the pane's own
9
+ * `scrollTop` cannot move anything else.
10
+ *
11
+ * Reader intent follows createStickToBottom's rules (actions/stickToBottom):
12
+ * wheel, touch drag, scroll keys and a scrollbar grab inside the pane are
13
+ * "the reader is reading", and follow pauses until they ask for it back
14
+ * (the "Jump to current" pill, or clicking a line). Plain `scroll` events are
15
+ * never intent — the follow's own scrolls and the windowing's height
16
+ * corrections fire them too.
17
+ */
18
+ /**
19
+ * Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
20
+ * coordinates) is fully visible, with `margin` px of breathing room — or null
21
+ * when it already is. "Nearest" semantics: move the least distance.
22
+ */
23
+ export function nearestScrollTop(rowTop, rowBottom, scrollTop, viewHeight, margin = 0) {
24
+ if (viewHeight <= 0)
25
+ return null;
26
+ const top = rowTop - margin;
27
+ const bottom = rowBottom + margin;
28
+ if (top >= scrollTop && bottom <= scrollTop + viewHeight)
29
+ return null;
30
+ // Taller than the pane, or above the view ⇒ align its top.
31
+ if (top < scrollTop || bottom - top > viewHeight)
32
+ return Math.max(0, top);
33
+ return Math.max(0, bottom - viewHeight);
34
+ }
35
+ /**
36
+ * Scroll `pane` (and nothing else) so `row` is visible. Returns true when it
37
+ * moved. `smooth` is the caller's call — pass false under reduced motion.
38
+ */
39
+ export function scrollRowIntoPane(pane, row, smooth, margin = 8) {
40
+ const p = pane.getBoundingClientRect();
41
+ const r = row.getBoundingClientRect();
42
+ const offset = pane.scrollTop - p.top - pane.clientTop;
43
+ const next = nearestScrollTop(r.top + offset, r.bottom + offset, pane.scrollTop, pane.clientHeight, margin);
44
+ if (next === null || Math.abs(next - pane.scrollTop) < 1)
45
+ return false;
46
+ if (smooth && typeof pane.scrollTo === 'function')
47
+ pane.scrollTo({ top: next, behavior: 'smooth' });
48
+ else
49
+ pane.scrollTop = next;
50
+ return true;
51
+ }
52
+ // Arrows, Space, Home and End belong to the player's keymap (seek, volume,
53
+ // play) — only the page keys are left to scroll the pane natively.
54
+ const SCROLL_KEYS = new Set(['PageUp', 'PageDown']);
55
+ /**
56
+ * Listen for reader scroll intent on `pane`; `onIntent` fires on each one.
57
+ */
58
+ export function watchReaderScroll(pane, onIntent) {
59
+ const wheel = () => onIntent();
60
+ const touch = () => onIntent();
61
+ const key = (e) => {
62
+ if (SCROLL_KEYS.has(e.key))
63
+ onIntent();
64
+ };
65
+ // A press on the pane's own box beyond its client width is the scrollbar.
66
+ const down = (e) => {
67
+ if (e.target === pane && e.offsetX > pane.clientWidth)
68
+ onIntent();
69
+ };
70
+ pane.addEventListener('wheel', wheel, { passive: true });
71
+ pane.addEventListener('touchmove', touch, { passive: true });
72
+ pane.addEventListener('keydown', key);
73
+ pane.addEventListener('pointerdown', down);
74
+ return () => {
75
+ pane.removeEventListener('wheel', wheel);
76
+ pane.removeEventListener('touchmove', touch);
77
+ pane.removeEventListener('keydown', key);
78
+ pane.removeEventListener('pointerdown', down);
79
+ };
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",