@joewinke/jatui 0.38.3 → 0.38.4

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
@@ -69,7 +69,7 @@ Then import:
69
69
  | `Empty` | Zero-state surface — v1 thin copy/children block still works; pass `title` for the v2 hero layout (upstreamed from the IDE's EmptyState): named `glyph` (11 curated Heroicons), `size` (sm/md/lg), `pulse` (reduced-motion gated, tinted with `--color-primary`), `icon` snippet override, CTA `actions` snippet, `description`, extra `children` below. | `title` (opts into v2), `description`, `glyph`, `size`, `pulse`, `icon`/`actions` (snippets), `copy`, `class`, `children` |
70
70
  | `ErrorState` | Standard "this failed" surface (upstreamed from the IDE's ErrorState) — error-token themed icon, optional Retry button, extra `actions`, collapsible `details` block for raw error output. Centered card mirroring `Empty` v2. | `title`, `message`, `details`, `retryLabel`, `onRetry`, `size`, `actions` (snippet) |
71
71
  | `HunkDiffView` | Side-by-side diff with line numbers, red/green backgrounds, strikethrough on removed lines, inline word-level highlights via the `diff` lib. Used for reviewing YAML/text changes per hunk. | `oldText: string \| null`, `newText: string \| null`, `unified?: boolean` |
72
- | `JumpToLatest` | "Jump to latest" pill for streaming transcripts (pairs with `createStickToBottom`) — shows when the reader has scrolled away from the live edge, badges the unread arrival count, jumps + resumes following on click. Position inside a `relative` wrapper that also holds the scroll element. | `visible`, `unreadCount`, `label`, `onclick` |
72
+ | `JumpToLatest` | "Jump to latest" pill for streaming transcripts (pairs with `createStickToBottom`) — shows when the reader has scrolled away from the live edge, badges the unread arrival count, jumps + resumes following on click. Position inside a `relative` wrapper that also holds the scroll element. | `visible`, `unreadCount`, `label`, `text?` (visible words beside the arrow), `onclick` |
73
73
  | `ContextMenu` | Floating right-click (or programmatic) action menu, positioned via `use:floating` at a cursor point. Real `<button>`s, per-item `icon`/`variant`/`disabled`, dividers, click-outside + Escape dismiss, arrow-key/Home/End roving nav (focus starts on the first item). Items may declare `submit: SubmitConfig` to POST to a SvelteKit form action instead of (or in addition to) `onSelect` — a hidden `<form>` is built, requestSubmit()ed, then removed, so `use:enhance` fires exactly like a regular form submission. **Items also support nested `submenu: ContextMenuItem[]`** (right-side popover on hover/focus/ArrowRight, chevron indicator, ArrowLeft/Escape closes just the submenu — submenus open on parent-item focus **without stealing focus into the submenu**; ArrowRight is the explicit "enter the submenu" gesture) **and inline `content: Snippet`** (replaces the default icon+label row with an arbitrary widget — the wrapping button still closes the menu, so widgets that shouldn't must `event.stopPropagation()`). `autoFocus` (default true) can be set false to suppress auto-focus of the first item on open. | `open` (bindable), `x`, `y`, `items: ContextMenuItem[]`, `header?`, `zIndex?`, `autoFocus?`, `onClose?` |
74
74
  | `ContextMenuTrigger` | Wraps content with the standard right-click + "⋯" touch affordance, backed by `ContextMenu`. Use instead of hand-rolling `oncontextmenu` + a kebab button per row/card. | `items: ContextMenuItem[]`, `children` (snippet), `header?`, `triggerLabel?`, `hideButton?`, `zIndex?`, `class?` |
75
75
  | `Tooltip` | Component-shaped wrapper around the shared `use:tooltip` action — for cases where you can't easily add an attribute to the target element (wrap a chip / badge / icon in the tooltip instead). Warm/cool window + `aria-describedby` are shared with the action. Prefer `use:tooltip` directly when you own the element. | `content: string`, `placement?`, `delayMs?`, `disabled?`, `display?`, `children` (snippet) |
@@ -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'`), `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?` |
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?` |
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,7 +86,12 @@
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';
89
+ import {
90
+ scrollRowIntoPane,
91
+ watchReaderScroll,
92
+ FOLLOW_RESUME_IDLE_MS,
93
+ type ReaderScrollWatch
94
+ } from '../video/transcriptFollow';
90
95
  import JumpToLatest from './basic/JumpToLatest.svelte';
91
96
  import type {
92
97
  VideoBookmark,
@@ -241,8 +246,11 @@
241
246
  import type { ResumePoint } from '../video/advisors/resume-point';
242
247
  import { idleDetectionSupported } from '../video/presence';
243
248
  import {
249
+ coerce,
244
250
  createSettingsSession,
251
+ localSettingsStore,
245
252
  panelGroups,
253
+ settingSpec,
246
254
  VIDEO_SETTINGS,
247
255
  type SettingAvailability,
248
256
  type SettingsSession,
@@ -275,6 +283,10 @@
275
283
  startAt = null,
276
284
  transcriptPosition = 'below',
277
285
  transcriptMaxHeight,
286
+ followResumeMs = FOLLOW_RESUME_IDLE_MS,
287
+ theater = $bindable(false),
288
+ theaterButton = true,
289
+ onTheaterChange,
278
290
  /**
279
291
  * CORS mode for the <video> element — passed straight through as the
280
292
  * `crossorigin` attribute. `'anonymous'` asks the server for a
@@ -439,6 +451,29 @@
439
451
  * beside it (`transcriptPosition: 'side'`), the player column's height.
440
452
  */
441
453
  transcriptMaxHeight?: string;
454
+ /**
455
+ * A reader who scrolled the transcript pane pauses its follow; after
456
+ * this long with the pane left alone while playing, follow resumes by
457
+ * itself (ms, default 6000; 0 ⇒ only a seek, a line/chapter click,
458
+ * play after pause or the "Resume following" pill bring it back).
459
+ */
460
+ followResumeMs?: number;
461
+ /**
462
+ * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
463
+ * only says it wants the page's width; the HOST lays its page out
464
+ * full-width (`bind:theater` or `onTheaterChange`). The player itself
465
+ * marks its root `vp-theater` / `data-theater` and puts a side
466
+ * transcript below the video. The control-bar button and `t` toggle
467
+ * it; the choice is remembered per viewer under the `player.theater`
468
+ * setting — through `settingsStore` when one is given, else this
469
+ * browser's localStorage (every access guarded). The prop is the
470
+ * host's default; a remembered choice wins.
471
+ */
472
+ theater?: boolean;
473
+ /** Show the theater button + `t` key (video only; default true). */
474
+ theaterButton?: boolean;
475
+ /** The viewer toggled theater mode (button, `t`, settings panel). */
476
+ onTheaterChange?: (theater: boolean) => void;
442
477
  crossorigin?: 'anonymous' | 'use-credentials' | null;
443
478
  syncUrlTime?: boolean;
444
479
  /**
@@ -859,8 +894,20 @@
859
894
  });
860
895
 
861
896
  let transcriptEl: HTMLDivElement | null = null;
862
- /** The reader scrolled the pane themselves — auto-follow waits for "Jump to current". */
897
+ /** The reader scrolled the pane themselves — auto-follow waits (see transcriptFollow.ts). */
863
898
  let followPaused = $state(false);
899
+ /** When the reader last scrolled the pane (performance.now()), for the idle resume. */
900
+ let lastReaderScrollAt = 0;
901
+ let readerWatch: ReaderScrollWatch | null = null;
902
+ /** The player scrolls the pane itself: fence it off from reader intent. */
903
+ function paneScrollTop(pane: HTMLElement, top: number) {
904
+ readerWatch?.programmatic();
905
+ pane.scrollTop = top;
906
+ }
907
+ function scrollPaneTo(pane: HTMLElement, row: HTMLElement) {
908
+ readerWatch?.programmatic();
909
+ return scrollRowIntoPane(pane, row, !reduceMotion);
910
+ }
864
911
  /** Height of the main column (stage + band), for a side transcript's bound. */
865
912
  let mainEl: HTMLDivElement | undefined = $state();
866
913
  let mainHeight = $state(0);
@@ -890,7 +937,7 @@
890
937
  transPadTop = 0;
891
938
  pendingAutoScrollId = null;
892
939
  vpScrollTop = 0;
893
- if (transcriptEl) transcriptEl.scrollTop = 0;
940
+ if (transcriptEl) paneScrollTop(transcriptEl, 0);
894
941
  }
895
942
 
896
943
  /** Append-only growth: heights measured for the prefix stay valid, so only
@@ -1037,7 +1084,7 @@
1037
1084
  const after = ensureOffsets();
1038
1085
  const delta = (after[anchor] ?? beforeTop) - beforeTop;
1039
1086
  if (el && delta !== 0 && st >= beforeTop) {
1040
- el.scrollTop = st + delta; // fires onscroll → vpScrollTop follows
1087
+ paneScrollTop(el, st + delta); // fires onscroll → vpScrollTop follows
1041
1088
  }
1042
1089
  }
1043
1090
 
@@ -1057,13 +1104,16 @@
1057
1104
  scheduleRowMeasure();
1058
1105
  });
1059
1106
  ro.observe(el);
1060
- const unwatch = watchReaderScroll(el, () => {
1107
+ const watch = watchReaderScroll(el, () => {
1108
+ lastReaderScrollAt = performance.now();
1061
1109
  if (activeSegment) followPaused = true;
1062
1110
  });
1111
+ readerWatch = watch;
1063
1112
  return {
1064
1113
  destroy() {
1065
1114
  ro.disconnect();
1066
- unwatch();
1115
+ watch();
1116
+ if (readerWatch === watch) readerWatch = null;
1067
1117
  transcriptEl = null;
1068
1118
  }
1069
1119
  };
@@ -1228,6 +1278,7 @@
1228
1278
  cache: layerCache,
1229
1279
  colors: {
1230
1280
  accent: getDaisyUIColor('primary'),
1281
+ chapter: getDaisyUIColor('accent') || getDaisyUIColor('primary'),
1231
1282
  neutral: getDaisyUIColor('base-content'),
1232
1283
  marker: getDaisyUIColor('warning'),
1233
1284
  ribbon: getDaisyUIColor('warning'),
@@ -1295,6 +1346,7 @@
1295
1346
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
1296
1347
  if (row.when === 'night') return nightPresent;
1297
1348
  if (row.when === 'settings') return settingsPanel;
1349
+ if (row.when === 'theater') return theaterAvailable;
1298
1350
  return true;
1299
1351
  })
1300
1352
  );
@@ -1347,6 +1399,7 @@
1347
1399
  const t = Math.max(0, d > 0 ? Math.min(sec, d) : sec);
1348
1400
  currentTime = t; // bind:currentTime pushes this to the element
1349
1401
  if (videoEl) videoEl.currentTime = t;
1402
+ resumeFollow();
1350
1403
  }
1351
1404
 
1352
1405
  // ── Speed + rate composer (video/rate/*, mos-5lzns.32) ──────────────────
@@ -2171,6 +2224,14 @@
2171
2224
  if (night !== undefined) nightDepth = v as number;
2172
2225
  return;
2173
2226
  }
2227
+ if (id === 'player.theater') {
2228
+ if (!settingsStore) localTheaterStore.set(id, v);
2229
+ if (theater !== v) {
2230
+ theater = v as boolean;
2231
+ onTheaterChange?.(theater);
2232
+ }
2233
+ return;
2234
+ }
2174
2235
  userSettings[id] = v;
2175
2236
  }
2176
2237
 
@@ -2193,6 +2254,30 @@
2193
2254
  };
2194
2255
  });
2195
2256
 
2257
+ // ── Theater mode (jatui-ee2ty) ──────────────────────────────────────────
2258
+ // Not fullscreen: the host lays its page out full-width off `theater`.
2259
+ // Remembered per viewer as the `player.theater` setting — the host's
2260
+ // settingsStore when given (the session above reads and writes it), else
2261
+ // this browser's localStorage, read once here.
2262
+ const localTheaterStore = localSettingsStore();
2263
+ const theaterAvailable = $derived(!isAudio && theaterButton);
2264
+ const theaterOn = $derived(theaterAvailable && theater);
2265
+ $effect(() => {
2266
+ if (!theaterAvailable || settingsStore) return;
2267
+ untrack(() => {
2268
+ const spec = settingSpec('player.theater');
2269
+ const stored = spec ? coerce(spec, localTheaterStore.get('player.theater')) : undefined;
2270
+ if (stored !== undefined && stored !== theater) applySetting('player.theater', stored);
2271
+ });
2272
+ });
2273
+ function toggleTheater() {
2274
+ if (!theaterAvailable) return;
2275
+ const next = !theaterOn;
2276
+ if (settingsSession) settingsSession.change('player.theater', next);
2277
+ else applySetting('player.theater', next);
2278
+ flashOsd(next ? 'Theater mode' : 'Default view');
2279
+ }
2280
+
2196
2281
  /** A viewer choice from outside the panel (N, the moon wheel) — saved like a panel change. */
2197
2282
  function rememberSetting(id: string, v: SettingValue) {
2198
2283
  settingsSession?.change(id, v);
@@ -2212,7 +2297,8 @@
2212
2297
  windDown: windDownProp !== undefined,
2213
2298
  idle: windDownProp !== undefined && idleDetectionSupported(),
2214
2299
  resume: resumeKey != null,
2215
- open: openFrom !== undefined
2300
+ open: openFrom !== undefined,
2301
+ theater: theaterAvailable
2216
2302
  });
2217
2303
  const settingsGroups = $derived(settingsOpen ? panelGroups(settingsAvailable) : []);
2218
2304
  /** What the player is doing right now, per id — the panel shows exactly this. */
@@ -2236,7 +2322,8 @@
2236
2322
  idleDetection,
2237
2323
  resumeSnap,
2238
2324
  openMotion,
2239
- oneAxis
2325
+ oneAxis,
2326
+ theater: theaterOn
2240
2327
  };
2241
2328
  const out: Record<string, SettingValue> = {};
2242
2329
  for (const spec of VIDEO_SETTINGS) {
@@ -2838,6 +2925,12 @@
2838
2925
  }
2839
2926
  return;
2840
2927
  case 't':
2928
+ // t = theater (YouTube's key); Shift+T = transcript (jatui-ee2ty).
2929
+ if (theaterAvailable) {
2930
+ e.preventDefault();
2931
+ toggleTheater();
2932
+ }
2933
+ return;
2841
2934
  case 'T':
2842
2935
  if (sortedSegments.length > 0) {
2843
2936
  e.preventDefault();
@@ -2929,7 +3022,7 @@
2929
3022
  if (!pane) return;
2930
3023
  const node = rowRefs[seg.id];
2931
3024
  if (node) {
2932
- scrollRowIntoPane(pane, node, !reduceMotion);
3025
+ scrollPaneTo(pane, node);
2933
3026
  return;
2934
3027
  }
2935
3028
  if (!transVirtual) return;
@@ -2937,7 +3030,7 @@
2937
3030
  if (idx == null) return;
2938
3031
  const top = ensureOffsets()[idx] ?? 0;
2939
3032
  // Row top (in content coords) = pane padding + offset; land it ⅓ down.
2940
- pane.scrollTop = Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3);
3033
+ paneScrollTop(pane, Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3));
2941
3034
  pendingAutoScrollId = seg.id;
2942
3035
  }
2943
3036
 
@@ -2954,9 +3047,32 @@
2954
3047
  if (activeSegment) followSegment(activeSegment);
2955
3048
  }
2956
3049
 
3050
+ /**
3051
+ * Choosing where to be — a seek, a chapter, a line, play after pause —
3052
+ * is asking for the transcript back (jatui-ee2ty). The follow effect
3053
+ * lands the new active line; if it didn't change, land it here.
3054
+ */
3055
+ function resumeFollow() {
3056
+ if (!followPaused) return;
3057
+ followPaused = false;
3058
+ const seg = activeSegment;
3059
+ if (seg) queueMicrotask(() => !followPaused && activeSegment === seg && followSegment(seg));
3060
+ }
3061
+
3062
+ // Idle resume: a reader who scrolled away and then left the pane alone for
3063
+ // `followResumeMs` while it plays gets the follow back by itself.
3064
+ $effect(() => {
3065
+ const ms = followResumeMs;
3066
+ if (!followPaused || paused || !(ms > 0)) return;
3067
+ const tick = () => {
3068
+ if (performance.now() - lastReaderScrollAt >= ms) untrack(resumeFollow);
3069
+ };
3070
+ const id = setInterval(tick, Math.min(1000, Math.max(100, ms / 4)));
3071
+ return () => clearInterval(id);
3072
+ });
3073
+
2957
3074
  /** Clicking a line is choosing where to be: seek there and follow again. */
2958
3075
  function onRowClick(seg: VideoTranscriptSegment) {
2959
- followPaused = false;
2960
3076
  seekTo(seg.startSec);
2961
3077
  }
2962
3078
 
@@ -2985,7 +3101,7 @@
2985
3101
  const node = rowRefs[id];
2986
3102
  if (!node) return;
2987
3103
  pendingAutoScrollId = null;
2988
- if (transcriptEl && !followPaused) scrollRowIntoPane(transcriptEl, node, !reduceMotion);
3104
+ if (transcriptEl && !followPaused) scrollPaneTo(transcriptEl, node);
2989
3105
  });
2990
3106
  });
2991
3107
 
@@ -3013,7 +3129,9 @@
3013
3129
  <div
3014
3130
  class="vp-root {className}"
3015
3131
  bind:this={rootEl}
3016
- class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
3132
+ class:vp-side={transcriptPosition === 'side' && !theaterOn && sortedSegments.length > 0 && transcriptVisible}
3133
+ class:vp-theater={theaterOn}
3134
+ data-theater={theaterAvailable ? String(theaterOn) : undefined}
3017
3135
  class:vp-rail-side={railOn}
3018
3136
  class:vp-one-axis={nativeBarHidden}
3019
3137
  class:vp-band-transport={bandTransport}
@@ -3081,6 +3199,7 @@
3081
3199
  if (nightPresent || guardPresent) nightTick();
3082
3200
  }}
3083
3201
  onplay={() => {
3202
+ resumeFollow();
3084
3203
  if (nightPresent || guardPresent) nightTick();
3085
3204
  if (windPhase === 'paused') windCancel();
3086
3205
  if (windOn) windProbe();
@@ -3285,6 +3404,23 @@
3285
3404
  </svg>
3286
3405
  </button>
3287
3406
  {/if}
3407
+ {#if theaterAvailable}
3408
+ <!-- Theater (jatui-ee2ty): asks the host for the page's width —
3409
+ not fullscreen. Lit while on, like the moon. -->
3410
+ <button
3411
+ type="button"
3412
+ class="vp-theater-btn"
3413
+ class:active={theaterOn}
3414
+ aria-label="Theater mode"
3415
+ aria-pressed={theaterOn}
3416
+ title="{theaterOn ? 'Default view' : 'Theater mode'} · t"
3417
+ onclick={toggleTheater}
3418
+ >
3419
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true">
3420
+ <rect x="2.5" y="7" width="19" height="10" rx="1.5" />
3421
+ </svg>
3422
+ </button>
3423
+ {/if}
3288
3424
  {#if settingsPanel}
3289
3425
  <button
3290
3426
  type="button"
@@ -3628,7 +3764,8 @@
3628
3764
  </div>
3629
3765
  <JumpToLatest
3630
3766
  visible={followPaused && activeSegment != null}
3631
- label="Jump to current"
3767
+ label="Resume following"
3768
+ text="Resume following"
3632
3769
  onclick={jumpToCurrent}
3633
3770
  />
3634
3771
  </div>
@@ -4836,7 +4973,8 @@
4836
4973
  gap: var(--jatui-space-base, 8px);
4837
4974
  }
4838
4975
 
4839
- .vp-night-btn {
4976
+ .vp-night-btn,
4977
+ .vp-theater-btn {
4840
4978
  display: inline-flex;
4841
4979
  align-items: center;
4842
4980
  justify-content: center;
@@ -4849,11 +4987,13 @@
4849
4987
  transition: color var(--motion-move, 300ms) var(--motion-glide, ease-out);
4850
4988
  }
4851
4989
 
4852
- .vp-night-btn:hover {
4990
+ .vp-night-btn:hover,
4991
+ .vp-theater-btn:hover {
4853
4992
  color: var(--color-base-content);
4854
4993
  }
4855
4994
 
4856
- .vp-night-btn:focus-visible {
4995
+ .vp-night-btn:focus-visible,
4996
+ .vp-theater-btn:focus-visible {
4857
4997
  outline: 2px solid var(--color-primary);
4858
4998
  outline-offset: 1px;
4859
4999
  }
@@ -4863,6 +5003,11 @@
4863
5003
  color: var(--color-warning);
4864
5004
  }
4865
5005
 
5006
+ /* Theater (jatui-ee2ty): lit in the primary while the page is widened. */
5007
+ .vp-theater-btn.active {
5008
+ color: var(--color-primary);
5009
+ }
5010
+
4866
5011
  /* Under reduced motion the veil still dims — the dimming IS the feature —
4867
5012
  but the long 900ms/1200ms ramps shrink to a short luminance fade. A
4868
5013
  change in brightness is not motion, and a hard step every tick would be
@@ -4881,7 +5026,8 @@
4881
5026
  transition-duration: 120ms;
4882
5027
  }
4883
5028
 
4884
- .vp-night-btn {
5029
+ .vp-night-btn,
5030
+ .vp-theater-btn {
4885
5031
  transition: none;
4886
5032
  }
4887
5033
  }
@@ -169,6 +169,29 @@ type $$ComponentProps = {
169
169
  * beside it (`transcriptPosition: 'side'`), the player column's height.
170
170
  */
171
171
  transcriptMaxHeight?: string;
172
+ /**
173
+ * A reader who scrolled the transcript pane pauses its follow; after
174
+ * this long with the pane left alone while playing, follow resumes by
175
+ * itself (ms, default 6000; 0 ⇒ only a seek, a line/chapter click,
176
+ * play after pause or the "Resume following" pill bring it back).
177
+ */
178
+ followResumeMs?: number;
179
+ /**
180
+ * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
181
+ * only says it wants the page's width; the HOST lays its page out
182
+ * full-width (`bind:theater` or `onTheaterChange`). The player itself
183
+ * marks its root `vp-theater` / `data-theater` and puts a side
184
+ * transcript below the video. The control-bar button and `t` toggle
185
+ * it; the choice is remembered per viewer under the `player.theater`
186
+ * setting — through `settingsStore` when one is given, else this
187
+ * browser's localStorage (every access guarded). The prop is the
188
+ * host's default; a remembered choice wins.
189
+ */
190
+ theater?: boolean;
191
+ /** Show the theater button + `t` key (video only; default true). */
192
+ theaterButton?: boolean;
193
+ /** The viewer toggled theater mode (button, `t`, settings panel). */
194
+ onTheaterChange?: (theater: boolean) => void;
172
195
  crossorigin?: 'anonymous' | 'use-credentials' | null;
173
196
  syncUrlTime?: boolean;
174
197
  /**
@@ -424,6 +447,6 @@ type $$ComponentProps = {
424
447
  declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
425
448
  bandState: () => BandState;
426
449
  seekToTime: (sec: number) => void;
427
- }, "night" | "nightDepth">;
450
+ }, "night" | "theater" | "nightDepth">;
428
451
  type VideoPlayer = ReturnType<typeof VideoPlayer>;
429
452
  export default VideoPlayer;
@@ -19,11 +19,14 @@
19
19
  visible = false,
20
20
  unreadCount = 0,
21
21
  label = 'Jump to latest',
22
+ text = '',
22
23
  onclick
23
24
  }: {
24
25
  visible?: boolean;
25
26
  unreadCount?: number;
26
27
  label?: string;
28
+ /** Visible words beside the arrow (default none — icon only). Keep it a prefix of `label`. */
29
+ text?: string;
27
30
  onclick?: () => void;
28
31
  } = $props();
29
32
  </script>
@@ -41,6 +44,7 @@
41
44
  <span class="font-semibold tabular-nums">{unreadCount}</span>
42
45
  <span>new</span>
43
46
  {/if}
47
+ {#if text}<span>{text}</span>{/if}
44
48
  <svg
45
49
  xmlns="http://www.w3.org/2000/svg"
46
50
  class="size-4"
@@ -2,6 +2,8 @@ type $$ComponentProps = {
2
2
  visible?: boolean;
3
3
  unreadCount?: number;
4
4
  label?: string;
5
+ /** Visible words beside the arrow (default none — icon only). Keep it a prefix of `label`. */
6
+ text?: string;
5
7
  onclick?: () => void;
6
8
  };
7
9
  declare const JumpToLatest: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -87,6 +87,12 @@ export interface BandPaintState {
87
87
  marker: string;
88
88
  /** The light ribbon's wash (FEATURES.md: amber). Defaults to `marker`. */
89
89
  ribbon?: string;
90
+ /**
91
+ * The hovered chapter's span (jatui-ee2ty) — the theme's `accent`, so
92
+ * it reads as a different colour from the played (primary) envelope.
93
+ * Defaults to `accent` (this state's primary).
94
+ */
95
+ chapter?: string;
90
96
  /** Comment dots. Defaults to `accent`. */
91
97
  comment?: string;
92
98
  /** Like heat strip. Defaults to `accent`. */
@@ -114,6 +120,10 @@ export declare function prepareCanvas(canvas: HTMLCanvasElement): CanvasRenderin
114
120
  export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: number, s: BandPaintState): void;
115
121
  /** Height of the accent rule along the top of a lit chapter span, px. */
116
122
  export declare const LIT_RULE_H = 2;
123
+ /** Width of the full-height edge lines at a lit chapter's bounds, px. */
124
+ export declare const LIT_EDGE_W = 2;
125
+ /** Opacity of the lit chapter's fill — clearly visible on light and dark themes. */
126
+ export declare const LIT_FILL_ALPHA = 0.35;
117
127
  /**
118
128
  * Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
119
129
  * start (or the end of the video) — through the zoom window, clipped to the
@@ -161,17 +161,22 @@ 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
164
+ // The lit chapter (jatui-ap4oz, strengthened in jatui-ee2ty): the whole
165
+ // column in the chapter colour at LIT_FILL_ALPHA, full-height edge lines
166
+ // at its bounds and a rule along the top — under every other layer so
167
+ // the envelope, flags and playhead stay legible on top. Static — nothing
167
168
  // animates, so it is reduced-motion safe by construction.
168
169
  const lit = chapterSpanPx(s.chapters, s.hoverChapter, s.window, dur, W);
169
170
  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);
171
+ const w = lit.x1 - lit.x0;
172
+ ctx.fillStyle = s.colors.chapter ?? accent;
173
+ ctx.globalAlpha = LIT_FILL_ALPHA;
174
+ ctx.fillRect(lit.x0, 0, w, H);
175
+ ctx.globalAlpha = 0.95;
176
+ ctx.fillRect(lit.x0, 0, w, LIT_RULE_H);
177
+ const e = Math.min(LIT_EDGE_W, w / 2);
178
+ ctx.fillRect(lit.x0, 0, e, H);
179
+ ctx.fillRect(lit.x1 - e, 0, e, H);
175
180
  ctx.globalAlpha = 1;
176
181
  }
177
182
  const p = s.peaks;
@@ -325,6 +330,10 @@ export function paintBand(ctx, W, H, s) {
325
330
  }
326
331
  /** Height of the accent rule along the top of a lit chapter span, px. */
327
332
  export const LIT_RULE_H = 2;
333
+ /** Width of the full-height edge lines at a lit chapter's bounds, px. */
334
+ export const LIT_EDGE_W = 2;
335
+ /** Opacity of the lit chapter's fill — clearly visible on light and dark themes. */
336
+ export const LIT_FILL_ALPHA = 0.35;
328
337
  /**
329
338
  * Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
330
339
  * start (or the end of the video) — through the zoom window, clipped to the
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Map parity is with extension-library's theater handler (FEATURES.md):
7
7
  * Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
8
- * speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
8
+ * speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
9
+ * (t is theater mode, YouTube's key — jatui-ee2ty), ?
9
10
  * shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
10
11
  * library-app verbs, not player verbs), `/` (transcript search — the pane
11
12
  * has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
@@ -20,7 +21,7 @@ export interface PlayerKeyRow {
20
21
  * Availability gate: the row is only advertised (and the key only bound)
21
22
  * when the feature's data exists. Undefined ⇒ always active.
22
23
  */
23
- when?: 'chapters' | 'rail' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings';
24
+ when?: 'chapters' | 'rail' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings' | 'theater';
24
25
  }
25
26
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
26
27
  export declare const PLAYER_KEYS: PlayerKeyRow[];
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Map parity is with extension-library's theater handler (FEATURES.md):
7
7
  * Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
8
- * speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
8
+ * speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
9
+ * (t is theater mode, YouTube's key — jatui-ee2ty), ?
9
10
  * shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
10
11
  * library-app verbs, not player verbs), `/` (transcript search — the pane
11
12
  * has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
@@ -30,7 +31,9 @@ export const PLAYER_KEYS = [
30
31
  // and "people's reactions to it" sit under one hand (mos-5lzns.30).
31
32
  { keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
32
33
  { keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
33
- { keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
34
+ // t = theater, YouTube's key; the transcript moved to Shift+T (jatui-ee2ty).
35
+ { keys: 't', description: 'Theater mode on / off', when: 'theater' },
36
+ { keys: 'Shift+T', description: 'Show / hide the transcript', when: 'transcript' },
34
37
  { keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
35
38
  { keys: 'Home / End', description: 'Jump to start / end' },
36
39
  { keys: ',', description: 'Settings', when: 'settings' },
@@ -37,7 +37,7 @@ export type SettingValue = boolean | number | string;
37
37
  * a switch that does nothing. The component resolves them (see
38
38
  * `SettingAvailability`); the registry only names them.
39
39
  */
40
- export type SettingWhen = 'waveform' | 'speakers' | 'ads' | 'loudness' | 'quiet' | 'limiter' | 'night' | 'nightLuma' | 'flashes' | 'windDown' | 'idle' | 'resume' | 'open';
40
+ export type SettingWhen = 'waveform' | 'speakers' | 'ads' | 'loudness' | 'quiet' | 'limiter' | 'night' | 'nightLuma' | 'flashes' | 'windDown' | 'idle' | 'resume' | 'open' | 'theater';
41
41
  export type SettingAvailability = Partial<Record<SettingWhen, boolean>>;
42
42
  export interface SettingOption {
43
43
  value: string;
@@ -281,6 +281,18 @@ export const VIDEO_SETTINGS = [
281
281
  hint: 'One time axis instead of two. The waveform is always on screen and already seeks, so the progress bar stands down and hands it the job — and comes back as an overview while the waveform is zoomed. Turn this off to keep both bars.',
282
282
  owner: 'mos-5lzns.30',
283
283
  wired: true
284
+ },
285
+ {
286
+ id: 'player.theater',
287
+ group: 'Playback',
288
+ label: 'Theater mode',
289
+ type: 'bool',
290
+ default: false,
291
+ prop: 'theater',
292
+ when: 'theater',
293
+ hint: "Widens the player to the page's full width — not fullscreen, so the rest of the page is still there below it — and puts a side transcript underneath. t toggles it, and the player remembers your choice.",
294
+ owner: 'jatui-ee2ty',
295
+ wired: true
284
296
  }
285
297
  ];
286
298
  const BY_ID = new Map(VIDEO_SETTINGS.map((s) => [s.id, s]));
@@ -46,6 +46,14 @@ export interface SettingsSessionOpts {
46
46
  }
47
47
  export declare const SETTINGS_WRITE_DELAY_MS = 400;
48
48
  export declare function createSettingsSession(opts: SettingsSessionOpts): SettingsSession;
49
+ /**
50
+ * This browser's localStorage as a settings store, one entry per id under
51
+ * `prefix` (jatui-ee2ty) — the fallback VideoPlayer uses for `player.theater`
52
+ * when the host gives no `settingsStore`. Every access is guarded: no
53
+ * storage (SSR, a private window, blocked site data) reads as nothing stored
54
+ * and drops writes; it never throws.
55
+ */
56
+ export declare function localSettingsStore(prefix?: string): VideoSettingsStore;
49
57
  /**
50
58
  * A plain in-memory store — for demos and tests, and for a host that wants
51
59
  * to hold the values itself (read them back with `.values`).
@@ -100,6 +100,42 @@ export function createSettingsSession(opts) {
100
100
  }
101
101
  };
102
102
  }
103
+ /**
104
+ * This browser's localStorage as a settings store, one entry per id under
105
+ * `prefix` (jatui-ee2ty) — the fallback VideoPlayer uses for `player.theater`
106
+ * when the host gives no `settingsStore`. Every access is guarded: no
107
+ * storage (SSR, a private window, blocked site data) reads as nothing stored
108
+ * and drops writes; it never throws.
109
+ */
110
+ export function localSettingsStore(prefix = 'jatui.video.') {
111
+ const ls = () => {
112
+ try {
113
+ return typeof localStorage === 'undefined' ? null : localStorage;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ };
119
+ return {
120
+ get(id) {
121
+ try {
122
+ const raw = ls()?.getItem(prefix + id);
123
+ return raw == null ? undefined : JSON.parse(raw);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ },
129
+ set(id, v) {
130
+ try {
131
+ ls()?.setItem(prefix + id, JSON.stringify(v));
132
+ }
133
+ catch {
134
+ /* full or blocked — the choice lasts while the player does */
135
+ }
136
+ }
137
+ };
138
+ }
103
139
  /**
104
140
  * A plain in-memory store — for demos and tests, and for a host that wants
105
141
  * to hold the values itself (read them back with `.values`).
@@ -8,12 +8,13 @@
8
8
  * video out of frame on load and on every new line. Setting the pane's own
9
9
  * `scrollTop` cannot move anything else.
10
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.
11
+ * Reader intent (jatui-ee2ty): a wheel, touch drag, page key or scrollbar
12
+ * grab inside the pane counts only when the PANE's own scrollTop moves as
13
+ * its result — a wheel over the pane that scrolls the page is not reading
14
+ * the transcript. Follow then pauses until it is asked back (the "Resume
15
+ * following" pill, clicking a line, a seek, play after pause) or the reader
16
+ * has left the pane alone for a few seconds while it plays. The follow's own
17
+ * scrolls and the windowing's height corrections are never intent.
17
18
  */
18
19
  /**
19
20
  * Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
@@ -26,7 +27,35 @@ export declare function nearestScrollTop(rowTop: number, rowBottom: number, scro
26
27
  * moved. `smooth` is the caller's call — pass false under reduced motion.
27
28
  */
28
29
  export declare function scrollRowIntoPane(pane: HTMLElement, row: HTMLElement, smooth: boolean, margin?: number): boolean;
30
+ /** How long after a gesture a pane scroll still counts as its result, ms. */
31
+ export declare const INTENT_WINDOW_MS = 300;
32
+ /** How long a programmatic (follow) scroll may take to land, ms — smooth scrolls animate. */
33
+ export declare const PROGRAMMATIC_WINDOW_MS = 700;
34
+ /** Default idle time in a paused pane, while playing, before follow resumes itself, ms. */
35
+ export declare const FOLLOW_RESUME_IDLE_MS = 6000;
36
+ export interface ReaderScrollWatch {
37
+ /** Stop listening. */
38
+ (): void;
39
+ /**
40
+ * The player is about to scroll the pane itself (follow, windowed jump).
41
+ * Scrolls until it lands — or until the reader's next gesture, which the
42
+ * browser lets interrupt it — are never intent.
43
+ */
44
+ programmatic(): void;
45
+ }
29
46
  /**
30
- * Listen for reader scroll intent on `pane`; `onIntent` fires on each one.
47
+ * Listen for reader scroll intent on `pane`; `onIntent` fires on each one
48
+ * (jatui-ee2ty).
49
+ *
50
+ * A gesture alone is NOT intent. Wheel, touch drag, the page keys and a
51
+ * scrollbar grab only ARM the watch with the pane's scrollTop at that
52
+ * moment; intent is the pane's OWN scrollTop then moving within
53
+ * `INTENT_WINDOW_MS` (or while the scrollbar is held). A wheel over the pane
54
+ * that chains to the page — the pane at its boundary, or the browser latched
55
+ * onto the window — leaves scrollTop where it was, so follow stays on. The
56
+ * player's own scrolls are fenced off with `programmatic()`.
31
57
  */
32
- export declare function watchReaderScroll(pane: HTMLElement, onIntent: () => void): () => void;
58
+ export declare function watchReaderScroll(pane: HTMLElement, onIntent: () => void, opts?: {
59
+ windowMs?: number;
60
+ now?: () => number;
61
+ }): ReaderScrollWatch;
@@ -8,12 +8,13 @@
8
8
  * video out of frame on load and on every new line. Setting the pane's own
9
9
  * `scrollTop` cannot move anything else.
10
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.
11
+ * Reader intent (jatui-ee2ty): a wheel, touch drag, page key or scrollbar
12
+ * grab inside the pane counts only when the PANE's own scrollTop moves as
13
+ * its result — a wheel over the pane that scrolls the page is not reading
14
+ * the transcript. Follow then pauses until it is asked back (the "Resume
15
+ * following" pill, clicking a line, a seek, play after pause) or the reader
16
+ * has left the pane alone for a few seconds while it plays. The follow's own
17
+ * scrolls and the windowing's height corrections are never intent.
17
18
  */
18
19
  /**
19
20
  * Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
@@ -52,29 +53,88 @@ export function scrollRowIntoPane(pane, row, smooth, margin = 8) {
52
53
  // Arrows, Space, Home and End belong to the player's keymap (seek, volume,
53
54
  // play) — only the page keys are left to scroll the pane natively.
54
55
  const SCROLL_KEYS = new Set(['PageUp', 'PageDown']);
56
+ /** How long after a gesture a pane scroll still counts as its result, ms. */
57
+ export const INTENT_WINDOW_MS = 300;
58
+ /** How long a programmatic (follow) scroll may take to land, ms — smooth scrolls animate. */
59
+ export const PROGRAMMATIC_WINDOW_MS = 700;
60
+ /** Default idle time in a paused pane, while playing, before follow resumes itself, ms. */
61
+ export const FOLLOW_RESUME_IDLE_MS = 6000;
55
62
  /**
56
- * Listen for reader scroll intent on `pane`; `onIntent` fires on each one.
63
+ * Listen for reader scroll intent on `pane`; `onIntent` fires on each one
64
+ * (jatui-ee2ty).
65
+ *
66
+ * A gesture alone is NOT intent. Wheel, touch drag, the page keys and a
67
+ * scrollbar grab only ARM the watch with the pane's scrollTop at that
68
+ * moment; intent is the pane's OWN scrollTop then moving within
69
+ * `INTENT_WINDOW_MS` (or while the scrollbar is held). A wheel over the pane
70
+ * that chains to the page — the pane at its boundary, or the browser latched
71
+ * onto the window — leaves scrollTop where it was, so follow stays on. The
72
+ * player's own scrolls are fenced off with `programmatic()`.
57
73
  */
58
- export function watchReaderScroll(pane, onIntent) {
59
- const wheel = () => onIntent();
60
- const touch = () => onIntent();
74
+ export function watchReaderScroll(pane, onIntent, opts = {}) {
75
+ const windowMs = opts.windowMs ?? INTENT_WINDOW_MS;
76
+ const now = opts.now ?? (() => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
77
+ let armedUntil = -Infinity;
78
+ let armedTop = 0;
79
+ let held = false;
80
+ let progUntil = -Infinity;
81
+ const armed = () => held || now() <= armedUntil;
82
+ const arm = () => {
83
+ // The reader's gesture interrupts any smooth follow scroll in flight:
84
+ // from here on, movement is theirs.
85
+ progUntil = -Infinity;
86
+ if (!armed())
87
+ armedTop = pane.scrollTop;
88
+ armedUntil = now() + windowMs;
89
+ };
90
+ const onScroll = () => {
91
+ if (now() <= progUntil) {
92
+ armedTop = pane.scrollTop; // the player moved it — re-base
93
+ return;
94
+ }
95
+ if (!armed())
96
+ return;
97
+ if (Math.abs(pane.scrollTop - armedTop) >= 1) {
98
+ armedUntil = -Infinity;
99
+ armedTop = pane.scrollTop;
100
+ onIntent();
101
+ }
102
+ };
61
103
  const key = (e) => {
62
104
  if (SCROLL_KEYS.has(e.key))
63
- onIntent();
105
+ arm();
64
106
  };
65
107
  // A press on the pane's own box beyond its client width is the scrollbar.
66
108
  const down = (e) => {
67
- if (e.target === pane && e.offsetX > pane.clientWidth)
68
- onIntent();
109
+ if (e.target === pane && e.offsetX > pane.clientWidth) {
110
+ arm();
111
+ held = true;
112
+ }
113
+ };
114
+ const up = () => {
115
+ if (!held)
116
+ return;
117
+ held = false;
118
+ armedUntil = now() + windowMs; // the last drag frame's scroll still counts
69
119
  };
70
- pane.addEventListener('wheel', wheel, { passive: true });
71
- pane.addEventListener('touchmove', touch, { passive: true });
120
+ pane.addEventListener('wheel', arm, { passive: true });
121
+ pane.addEventListener('touchmove', arm, { passive: true });
72
122
  pane.addEventListener('keydown', key);
73
123
  pane.addEventListener('pointerdown', down);
74
- return () => {
75
- pane.removeEventListener('wheel', wheel);
76
- pane.removeEventListener('touchmove', touch);
124
+ pane.addEventListener('scroll', onScroll, { passive: true });
125
+ if (typeof window !== 'undefined')
126
+ window.addEventListener('pointerup', up);
127
+ const stop = (() => {
128
+ pane.removeEventListener('wheel', arm);
129
+ pane.removeEventListener('touchmove', arm);
77
130
  pane.removeEventListener('keydown', key);
78
131
  pane.removeEventListener('pointerdown', down);
132
+ pane.removeEventListener('scroll', onScroll);
133
+ if (typeof window !== 'undefined')
134
+ window.removeEventListener('pointerup', up);
135
+ });
136
+ stop.programmatic = () => {
137
+ progUntil = now() + PROGRAMMATIC_WINDOW_MS;
79
138
  };
139
+ return stop;
80
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.38.3",
3
+ "version": "0.38.4",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",