@joewinke/jatui 0.31.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,8 @@
9
9
  VideoWaveform,
10
10
  VideoSpeakerTalkTime
11
11
  } from '../video/types';
12
+ // mos-777hj — chapter rail public types.
13
+ export type { RailChapter, RailChapterStatus } from '../video/chapterRail';
12
14
  // mos-5lzns.30 — one-axis state, the light track, social markers.
13
15
  export type { BandAxis, BandState } from '../video/band/axis';
14
16
  export type { VideoLumaTrack } from '../video/band/luma';
@@ -79,8 +81,11 @@
79
81
  * colour, cycled per speaker — not brand colour).
80
82
  */
81
83
 
82
- import { onMount, untrack } from 'svelte';
84
+ import { onMount, untrack, type Snippet } from 'svelte';
83
85
  import { getDaisyUIColor, watchThemeChanges, clearColorCache } from '../utils/daisyuiColors';
86
+ import { listNav } from '../actions/listNav';
87
+ // mos-777hj — the chapter rail's pure state (ends, played checks, progress).
88
+ import { railChapters, activeRailIndex, type RailChapter } from '../video/chapterRail';
84
89
  import type {
85
90
  VideoBookmark,
86
91
  VideoChapter,
@@ -246,6 +251,8 @@
246
251
 
247
252
  let {
248
253
  src,
254
+ kind = 'video',
255
+ transport: transportProp = undefined,
249
256
  poster = null,
250
257
  aspect = '16:9',
251
258
  autoplay = false,
@@ -255,6 +262,9 @@
255
262
  captionsLang = 'en',
256
263
  captionsDefault = true,
257
264
  chapters = [],
265
+ chapterRail: chapterRailProp = 'off',
266
+ onChapterSelect = undefined,
267
+ belowStage = undefined,
258
268
  speakers = [],
259
269
  transcript = [],
260
270
  bookmarks = [],
@@ -331,6 +341,23 @@
331
341
  class: className = ''
332
342
  }: {
333
343
  src: string;
344
+ /**
345
+ * What the media IS. `'audio'` (a podcast, a call recording, a brief)
346
+ * renders NO stage: there is no picture to frame, so the player is the
347
+ * band (the one timeline) plus its transport row. The media element
348
+ * still plays, just never shows. `aspect`/`poster` are ignored. Default
349
+ * `'video'`.
350
+ */
351
+ kind?: 'video' | 'audio';
352
+ /**
353
+ * Who owns play/pause, time and volume. `'native'` keeps the browser's
354
+ * `<video controls>` bar (which carries its own timeline next to the
355
+ * band). `'band'` drops the native controls and puts a transport row
356
+ * on the band instead: play/pause, `current / total`, speed, mute +
357
+ * volume, and fullscreen for video. One timeline, one set of controls.
358
+ * Default: `'band'` for `kind="audio"`, `'native'` for video.
359
+ */
360
+ transport?: 'native' | 'band';
334
361
  poster?: string | null;
335
362
  /**
336
363
  * Aspect ratio for the video stage — presentation-primitive prop (PRD).
@@ -369,6 +396,33 @@
369
396
  */
370
397
  captionsDefault?: boolean;
371
398
  chapters?: VideoChapter[];
399
+ /**
400
+ * mos-777hj — the live chapter rail. `'side'` (with chapters present)
401
+ * renders a sticky chapter pane beside the main column at ≥64rem — one
402
+ * row per chapter with time, title, status dot and summary, the active
403
+ * row tracking the playhead (aria-current, auto-scroll), a progress
404
+ * fill inside the active row and a played check behind the playhead —
405
+ * and a horizontal chip strip under the player below 64rem. Rows seek
406
+ * to the chapter and play; the active row carries the replay affordance
407
+ * (clicking it again replays from its start). Keyboard: listNav j/k to
408
+ * move, Enter to play, while the rail holds focus. Default `'off'` —
409
+ * the rail is opt-in and leaves the player exactly as it was.
410
+ */
411
+ chapterRail?: 'side' | 'off';
412
+ /**
413
+ * Fires when the viewer picks a chapter (row click / Enter / replay):
414
+ * the host pins its own view (e.g. a per-chapter visual panel) — the
415
+ * rail itself always keeps time-tracking the playhead.
416
+ */
417
+ onChapterSelect?: (chapter: VideoChapter, index: number) => void;
418
+ /**
419
+ * Optional snippet rendered between the stage and the transcript — the
420
+ * host's own content in the player's main column (e.g. a "now
421
+ * discussing" panel bound to the active chapter). Designed for
422
+ * `transcriptPosition: 'below'`; beside a side transcript it renders
423
+ * under the stage in the stage's column.
424
+ */
425
+ belowStage?: Snippet;
372
426
  speakers?: VideoSpeakerTurn[];
373
427
  transcript?: VideoTranscriptSegment[];
374
428
  bookmarks?: VideoBookmark[];
@@ -688,6 +742,53 @@
688
742
  let currentTime = $state(0);
689
743
  let duration = $state(0);
690
744
 
745
+ // ── Band transport (kind="audio" / transport="band") ────────────────────
746
+ // Audio has no picture, so no stage; the band is the only timeline and
747
+ // the transport row under it carries play/pause, time, speed and volume.
748
+ const isAudio = $derived(kind === 'audio');
749
+ const bandTransport = $derived(isAudio || transportProp === 'band');
750
+ let paused = $state(true);
751
+ let volumeLevel = $state(1);
752
+ let mutedNow = $state(false);
753
+ let isFullscreen = $state(false);
754
+ function syncVolume() {
755
+ if (!videoEl) return;
756
+ volumeLevel = videoEl.volume;
757
+ mutedNow = videoEl.muted;
758
+ }
759
+ function togglePlay() {
760
+ const v = videoEl;
761
+ if (!v) return;
762
+ if (v.paused) void v.play().catch(() => {});
763
+ else v.pause();
764
+ }
765
+ function toggleVolumeMute() {
766
+ const v = videoEl;
767
+ if (!v) return;
768
+ v.muted = !v.muted;
769
+ if (!v.muted && v.volume === 0) v.volume = 0.5;
770
+ }
771
+ function onVolumeInput(e: Event) {
772
+ const v = videoEl;
773
+ if (!v) return;
774
+ const next = Number((e.currentTarget as HTMLInputElement).value);
775
+ v.volume = Math.min(1, Math.max(0, next));
776
+ v.muted = next === 0;
777
+ }
778
+ function toggleFullscreen() {
779
+ if (typeof document === 'undefined') return;
780
+ if (document.fullscreenElement) void document.exitFullscreen?.();
781
+ else void stageWrapEl?.requestFullscreen?.();
782
+ }
783
+ /** The speed button: the listening speeds people actually pick, in
784
+ * order, wrapping back to 1×. `-`/`=` still walk the fine staircase. */
785
+ const SPEED_CYCLE = [1, 1.25, 1.5, 1.75, 2];
786
+ function cycleSpeed() {
787
+ baseRate = SPEED_CYCLE.find((s) => s > baseRate + 0.001) ?? 1;
788
+ applyRate();
789
+ flashOsd(formatSpeedLabel(baseRate));
790
+ }
791
+
691
792
  // Canvas box, tracked so a resize (panel toggle, window resize) repaints.
692
793
  let canvasW = $state(0);
693
794
  let canvasH = $state(0);
@@ -960,6 +1061,42 @@
960
1061
  const useHours = $derived(effectiveDuration >= 3600);
961
1062
 
962
1063
  const sortedChapters = $derived(sortChapters(chapters));
1064
+
1065
+ // ── Chapter rail (mos-777hj) ─────────────────────────────────────────
1066
+ /** The rail pane exists only when opted in AND there is something to rail. */
1067
+ const railOn = $derived(chapterRailProp === 'side' && sortedChapters.length > 0);
1068
+ /** Rows recomputed per playhead tick — ends, played checks, progress fill. */
1069
+ const railRows = $derived(railOn ? railChapters(sortedChapters, effectiveDuration, currentTime) : []);
1070
+ const railActive = $derived(railOn ? activeRailIndex(sortedChapters, currentTime) : -1);
1071
+ let chapterRailEl: HTMLElement | null = $state(null);
1072
+
1073
+ /** Row click / Enter / replay: seek to the chapter and play it. */
1074
+ function chapterGo(index: number) {
1075
+ const ch = sortedChapters[index];
1076
+ if (!ch) return;
1077
+ seekTo(ch.startSec);
1078
+ videoEl?.play()?.catch(() => {});
1079
+ onChapterSelect?.(ch, index);
1080
+ }
1081
+
1082
+ /** listNav onSelect — the element carries its row index. */
1083
+ function onRailSelect(el: HTMLElement) {
1084
+ const idx = Number(el.dataset.chapterIndex);
1085
+ if (Number.isInteger(idx)) chapterGo(idx);
1086
+ }
1087
+
1088
+ /** Keep the active row visible while playback tracks chapters. Nearest
1089
+ * only ever scrolls the rail's own overflow (never the page); smooth is
1090
+ * dropped under prefers-reduced-motion. */
1091
+ $effect(() => {
1092
+ if (!railOn || railActive < 0) return;
1093
+ const row = chapterRailEl?.querySelector<HTMLElement>('.vp-chapter-row.active');
1094
+ if (!row) return;
1095
+ row.scrollIntoView({
1096
+ block: 'nearest',
1097
+ behavior: reduceMotion ? 'auto' : 'smooth'
1098
+ });
1099
+ });
963
1100
  const sortedTurns = $derived(sortTurns(speakers));
964
1101
  const sortedSegments = $derived(sortSegments(transcript));
965
1102
  const sortedBookmarks = $derived(sortBookmarks(bookmarks));
@@ -988,6 +1125,9 @@
988
1125
  const speakerIndex = $derived(buildSpeakerIndex(sortedTurns, sortedSegments));
989
1126
 
990
1127
  const hasSpeakers = $derived(speakerIndex.size > 0);
1128
+ /** Two or more voices: only then do talk time, solo and skip mean
1129
+ * anything, and only then does a transcript row need its speaker name. */
1130
+ const multiSpeaker = $derived(speakerIndex.size > 1);
991
1131
 
992
1132
  /**
993
1133
  * Theme-derived speaker identity: colours + the fallback cycle in one read
@@ -1094,6 +1234,16 @@
1094
1234
  return { ...currentBandState };
1095
1235
  }
1096
1236
 
1237
+ /**
1238
+ * Callable on the instance (`bind:this`) — seek to an absolute second.
1239
+ * The host-side half of deep links (mos-777hj): a host that resolves its
1240
+ * own anchors (e.g. #chapter-<id>) seeks through this after mount, the
1241
+ * same place `startAt`/`?t=` seed from.
1242
+ */
1243
+ export function seekToTime(sec: number) {
1244
+ seekTo(sec);
1245
+ }
1246
+
1097
1247
  const reportBand = createBandReporter(() => onBandChange);
1098
1248
  $effect(() => {
1099
1249
  const st = currentBandState;
@@ -1105,6 +1255,7 @@
1105
1255
  PLAYER_KEYS.filter((row) => {
1106
1256
  if (!row.when) return true;
1107
1257
  if (row.when === 'chapters') return sortedChapters.length > 0;
1258
+ if (row.when === 'rail') return railOn;
1108
1259
  if (row.when === 'bookmarks') return onAddBookmark != null;
1109
1260
  if (row.when === 'transcript') return sortedSegments.length > 0;
1110
1261
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
@@ -2532,6 +2683,13 @@
2532
2683
  const target = e.target as HTMLElement | null;
2533
2684
  const tag = target?.tagName;
2534
2685
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
2686
+ // Space/Enter on a focused button activates THAT button (play, a
2687
+ // speaker chip, a transcript row); toggling playback too would undo it.
2688
+ if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
2689
+ // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
2690
+ // activation) while focus is inside it; without this guard a rail `k`
2691
+ // would also toggle play/pause and a rail `j` also seek ±5s.
2692
+ if (chapterRailEl && target && chapterRailEl.contains(target)) return;
2535
2693
  if (e.metaKey || e.ctrlKey || e.altKey) return;
2536
2694
  const v = videoEl;
2537
2695
  if (!v) return;
@@ -2786,13 +2944,16 @@
2786
2944
  class="vp-root {className}"
2787
2945
  bind:this={rootEl}
2788
2946
  class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
2947
+ class:vp-rail-side={railOn}
2789
2948
  class:vp-one-axis={nativeBarHidden}
2949
+ class:vp-band-transport={bandTransport}
2950
+ class:vp-audio={isAudio}
2790
2951
  class:vp-night-capable={nightPresent}
2791
2952
  class:vp-night-surround={surroundMix > 0.001}
2792
2953
  style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
2793
2954
  data-band-axis={currentBandState.axis}
2794
2955
  role="region"
2795
- aria-label="Video player"
2956
+ aria-label={isAudio ? 'Audio player' : 'Video player'}
2796
2957
  tabindex="-1"
2797
2958
  onkeydown={onRootKeyDown}
2798
2959
  onpointerdowncapture={maybeAskIdle}
@@ -2801,7 +2962,12 @@
2801
2962
  onkeydowncapture={maybeAskIdle}
2802
2963
  >
2803
2964
  <div class="vp-main">
2804
- <div class="vp-stage-wrap" bind:this={stageWrapEl}>
2965
+ <div
2966
+ class="vp-stage-wrap"
2967
+ class:vp-stage-audio={isAudio}
2968
+ bind:this={stageWrapEl}
2969
+ onfullscreenchange={() => (isFullscreen = document.fullscreenElement === stageWrapEl)}
2970
+ >
2805
2971
  {#if nightPresent}
2806
2972
  <!-- Bias light: the frame's own colours, blurred, spilled behind the
2807
2973
  picture. Earlier in the DOM than the (positioned) stage, so the
@@ -2813,9 +2979,13 @@
2813
2979
  aria-hidden="true"
2814
2980
  ></div>
2815
2981
  {/if}
2816
- <!-- svelte-ignore a11y_no_static_element_interactions -->
2982
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
2983
+ <!-- With the band transport the native controls are gone, so a click
2984
+ on the picture plays/pauses (Space/K do the same from the keyboard). -->
2817
2985
  <div
2818
2986
  class="vp-stage"
2987
+ class:vp-stage-clickable={bandTransport && !isAudio}
2988
+ onclick={bandTransport && !isAudio ? togglePlay : undefined}
2819
2989
  class:vp-stage-locked={stageAspect != null}
2820
2990
  class:vp-night-capable={nightPresent || guardPresent}
2821
2991
  class:vp-night-on={nightFrame != null}
@@ -2832,6 +3002,8 @@
2832
3002
  bind:this={videoEl}
2833
3003
  bind:currentTime
2834
3004
  bind:duration
3005
+ bind:paused
3006
+ onvolumechange={syncVolume}
2835
3007
  ontimeupdate={handleTimeUpdate}
2836
3008
  onloadedmetadata={onLoadedMetadata}
2837
3009
  onseeked={() => {
@@ -2857,8 +3029,8 @@
2857
3029
  {loop}
2858
3030
  crossorigin={crossorigin ?? undefined}
2859
3031
  muted={effectiveAutoplay || undefined}
2860
- poster={poster ?? undefined}
2861
- controls
3032
+ poster={isAudio ? undefined : (poster ?? undefined)}
3033
+ controls={!bandTransport}
2862
3034
  playsinline
2863
3035
  class="vp-video"
2864
3036
  >
@@ -2983,6 +3155,10 @@
2983
3155
  </div>
2984
3156
 
2985
3157
  <div class="vp-zoom" class:on={zoomLabelOn} aria-hidden="true">{zoomLabel}</div>
3158
+ {#if isAudio}
3159
+ <!-- No stage to float the readout over: it rides the band. -->
3160
+ <div class="vp-osd vp-osd-band" class:on={osdOn} aria-hidden="true">{osd}</div>
3161
+ {/if}
2986
3162
 
2987
3163
  {#if hover}
2988
3164
  <div
@@ -3012,8 +3188,7 @@
3012
3188
  {/if}
3013
3189
  </div>
3014
3190
 
3015
- <div class="vp-times">
3016
- <span>{formatTime(currentTime, useHours)}</span>
3191
+ {#snippet timesMid()}
3017
3192
  <span class="vp-times-mid">
3018
3193
  {#if isZoomedNow()}
3019
3194
  <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
@@ -3058,28 +3233,118 @@
3058
3233
  </button>
3059
3234
  {/if}
3060
3235
  </span>
3061
- <span>{formatTime(effectiveDuration, useHours)}</span>
3062
- </div>
3236
+ {/snippet}
3237
+
3238
+ {#if bandTransport}
3239
+ <!-- The band transport: the band above is the one timeline; this row
3240
+ carries everything the native bar used to (play/pause, time,
3241
+ speed, volume, fullscreen for video). -->
3242
+ <div class="vp-times vp-transport">
3243
+ <span class="vp-transport-start">
3244
+ <button
3245
+ type="button"
3246
+ class="vp-play"
3247
+ aria-label={paused ? 'Play' : 'Pause'}
3248
+ title="{paused ? 'Play' : 'Pause'} · Space"
3249
+ onclick={togglePlay}
3250
+ >
3251
+ {#if paused}
3252
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
3253
+ <path d="M8 5.5v13l10.5-6.5z" fill="currentColor" />
3254
+ </svg>
3255
+ {:else}
3256
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
3257
+ <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
3258
+ </svg>
3259
+ {/if}
3260
+ </button>
3261
+ <span class="vp-clock">
3262
+ <span class="vp-clock-now">{formatTime(currentTime, useHours)}</span>
3263
+ <span class="vp-clock-sep" aria-hidden="true">/</span>
3264
+ <span class="vp-sr">of</span>
3265
+ <span>{formatTime(effectiveDuration, useHours)}</span>
3266
+ </span>
3267
+ </span>
3268
+ {@render timesMid()}
3269
+ <span class="vp-transport-end">
3270
+ <button
3271
+ type="button"
3272
+ class="vp-tbtn vp-speed"
3273
+ aria-label="Playback speed {formatSpeedLabel(baseRate)}"
3274
+ title="Speed · click to change, - and = step"
3275
+ onclick={cycleSpeed}
3276
+ >
3277
+ {formatSpeedLabel(baseRate)}
3278
+ </button>
3279
+ <button
3280
+ type="button"
3281
+ class="vp-tbtn"
3282
+ aria-label={mutedNow || volumeLevel === 0 ? 'Unmute' : 'Mute'}
3283
+ aria-pressed={mutedNow}
3284
+ title="{mutedNow ? 'Unmute' : 'Mute'} · M"
3285
+ onclick={toggleVolumeMute}
3286
+ >
3287
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3288
+ <path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
3289
+ {#if mutedNow || volumeLevel === 0}
3290
+ <path d="M16 9.5l5 5M21 9.5l-5 5" />
3291
+ {:else}
3292
+ <path d="M15.5 9a4 4 0 0 1 0 6" />
3293
+ {#if volumeLevel > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
3294
+ {/if}
3295
+ </svg>
3296
+ </button>
3297
+ <input
3298
+ type="range"
3299
+ class="vp-volume"
3300
+ min="0"
3301
+ max="1"
3302
+ step="0.05"
3303
+ value={mutedNow ? 0 : volumeLevel}
3304
+ aria-label="Volume"
3305
+ aria-valuetext="{Math.round((mutedNow ? 0 : volumeLevel) * 100)}%"
3306
+ oninput={onVolumeInput}
3307
+ />
3308
+ {#if !isAudio}
3309
+ <button
3310
+ type="button"
3311
+ class="vp-tbtn"
3312
+ aria-label={isFullscreen ? 'Exit full screen' : 'Full screen'}
3313
+ title={isFullscreen ? 'Exit full screen' : 'Full screen'}
3314
+ onclick={toggleFullscreen}
3315
+ >
3316
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3317
+ {#if isFullscreen}
3318
+ <path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
3319
+ {:else}
3320
+ <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
3321
+ {/if}
3322
+ </svg>
3323
+ </button>
3324
+ {/if}
3325
+ </span>
3326
+ </div>
3327
+ {:else}
3328
+ <div class="vp-times">
3329
+ <span>{formatTime(currentTime, useHours)}</span>
3330
+ {@render timesMid()}
3331
+ <span>{formatTime(effectiveDuration, useHours)}</span>
3332
+ </div>
3333
+ {/if}
3063
3334
 
3064
- {#if hasSpeakers}
3335
+ {#if multiSpeaker}
3065
3336
  <!-- Talk-time doubles as the speaker legend: hover a chip to raise that
3066
- speaker on the timeline, click to solo, and use its mute toggle to
3067
- skip them. Identification through brightness, never a glow. -->
3068
- <div class="vp-talktime">
3069
- <div class="vp-talktime-bar">
3070
- {#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
3071
- <div
3072
- class="vp-talktime-seg"
3073
- style="width: {(tt.seconds / talkTimeTotal) * 100}%; background: {speakerColor(
3074
- talkTimeKey(tt)
3075
- )}"
3076
- title="{tt.speakerName ?? 'Unknown speaker'} — {formatDuration(tt.seconds)}"
3077
- ></div>
3078
- {/each}
3079
- </div>
3337
+ speaker on the timeline, click to solo, and use its skip toggle to
3338
+ skip them. Identification through brightness, never a glow. Only
3339
+ with 2+ speakers: one voice has nothing to compare, solo or skip,
3340
+ and its talk time reads as a second, different duration. Labelled
3341
+ "Talk time" so a chip's seconds never read as the media's length. -->
3342
+ <div class="vp-talktime" role="group" aria-label="Talk time by speaker">
3343
+ <span class="vp-talktime-label" aria-hidden="true">Talk time</span>
3080
3344
  <div class="vp-legend">
3081
3345
  {#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
3082
3346
  {@const key = talkTimeKey(tt)}
3347
+ {@const name = tt.speakerName ?? 'Unknown speaker'}
3083
3348
  <span class="vp-legend-item">
3084
3349
  <button
3085
3350
  type="button"
@@ -3092,10 +3357,11 @@
3092
3357
  onblur={() => (highlightKey = null)}
3093
3358
  onclick={() => toggleSolo(key)}
3094
3359
  aria-pressed={soloKey === key}
3095
- title="Solo {tt.speakerName ?? 'Unknown speaker'}"
3360
+ aria-label="{name}, talked {formatDuration(tt.seconds)}. Play only {name}"
3361
+ title="Play only {name}"
3096
3362
  >
3097
3363
  <span class="vp-chip-dot" style="background: {speakerColor(key)}"></span>
3098
- {tt.speakerName ?? 'Unknown speaker'}
3364
+ {name}
3099
3365
  <span class="vp-chip-time">{formatDuration(tt.seconds)}</span>
3100
3366
  </button>
3101
3367
  <button
@@ -3104,11 +3370,12 @@
3104
3370
  class:on={mutedKeys.has(key)}
3105
3371
  onclick={() => toggleMute(key)}
3106
3372
  aria-pressed={mutedKeys.has(key)}
3107
- aria-label="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} {tt.speakerName ??
3108
- 'Unknown speaker'}"
3109
- title="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} this speaker"
3373
+ aria-label="Skip {name}"
3374
+ title="{mutedKeys.has(key) ? 'Stop skipping' : 'Skip'} {name}"
3110
3375
  >
3111
- {mutedKeys.has(key) ? '🔇' : '🔈'}
3376
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3377
+ <path d="M5 6l8 6-8 6zM18 6v12" />
3378
+ </svg>
3112
3379
  </button>
3113
3380
  </span>
3114
3381
  {/each}
@@ -3121,7 +3388,7 @@
3121
3388
  mutedKeys = new Set();
3122
3389
  }}
3123
3390
  >
3124
- Clear filter
3391
+ Show all speakers
3125
3392
  </button>
3126
3393
  {/if}
3127
3394
  </div>
@@ -3144,6 +3411,79 @@
3144
3411
  {/if}
3145
3412
  </div>
3146
3413
 
3414
+ {#if railOn}
3415
+ <!-- mos-777hj — the live chapter rail: one row per chapter with time,
3416
+ title, status dot and summary. The active row tracks the playhead
3417
+ (aria-current + auto-scroll), fills its thin progress bar, and
3418
+ carries the replay affordance; played chapters wear a check. A row
3419
+ click seeks there and plays (clicking the active row again replays
3420
+ it from its start). listNav owns j/k + Enter while the rail holds
3421
+ focus (global:false scopes the listener to this pane; the root
3422
+ keydown handler skips events from inside it). ≥64rem the pane is a
3423
+ sticky column; below it collapses to a horizontal chip strip. -->
3424
+ <nav
3425
+ class="vp-chapters"
3426
+ bind:this={chapterRailEl}
3427
+ aria-label="Chapters"
3428
+ use:listNav={{
3429
+ itemSelector: '.vp-chapter-row',
3430
+ onSelect: onRailSelect,
3431
+ focusedClass: 'jk-focused',
3432
+ global: false
3433
+ }}
3434
+ >
3435
+ {#each railRows as ch, i (i)}
3436
+ <button
3437
+ type="button"
3438
+ class="vp-chapter-row"
3439
+ class:active={railActive === i}
3440
+ class:done={ch.played}
3441
+ data-chapter-index={i}
3442
+ aria-current={railActive === i ? 'true' : undefined}
3443
+ onclick={() => chapterGo(i)}
3444
+ >
3445
+ <span class="vp-chapter-head">
3446
+ {#if ch.status}
3447
+ <span class="vp-chapter-dot vp-chapter-dot-{ch.status}" aria-hidden="true"></span>
3448
+ {/if}
3449
+ <span class="vp-chapter-time">{formatTime(ch.startSec, useHours)}</span>
3450
+ <span class="vp-chapter-title">{ch.title}</span>
3451
+ {#if ch.played}
3452
+ <svg class="vp-chapter-check" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
3453
+ <path d="M2.5 8.5 6 12l7.5-8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
3454
+ </svg>
3455
+ {/if}
3456
+ {#if railActive === i}
3457
+ <!-- Replay affordance: the active row's click already replays
3458
+ from its start, so the icon is the affordance's label. -->
3459
+ <svg class="vp-chapter-replay" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
3460
+ <path d="M3 12a9 9 0 1 0 2.64-6.36M3 4v4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
3461
+ </svg>
3462
+ {/if}
3463
+ </span>
3464
+ {#if ch.summary}
3465
+ <span class="vp-chapter-summary">{ch.summary}</span>
3466
+ {/if}
3467
+ {#if railActive === i}
3468
+ <span
3469
+ class="vp-chapter-bar"
3470
+ aria-hidden="true"
3471
+ style="--vp-chapter-progress: {ch.progress}"
3472
+ >
3473
+ <span class="vp-chapter-fill"></span>
3474
+ </span>
3475
+ {/if}
3476
+ </button>
3477
+ {/each}
3478
+ </nav>
3479
+ {/if}
3480
+
3481
+ {#if belowStage}
3482
+ <!-- mos-777hj — the host's content in the main column: under the stage
3483
+ (and its band), above the transcript. -->
3484
+ <div class="vp-below-stage">{@render belowStage()}</div>
3485
+ {/if}
3486
+
3147
3487
  {#if sortedSegments.length > 0 && transcriptVisible}
3148
3488
  <div
3149
3489
  class="vp-transcript"
@@ -3160,6 +3500,7 @@
3160
3500
  <button
3161
3501
  type="button"
3162
3502
  class="vp-row"
3503
+ class:vp-row-one={!multiSpeaker}
3163
3504
  class:active={activeSegment?.id === seg.id}
3164
3505
  class:vp-dim={highlightKey != null
3165
3506
  ? highlightKey !== speakerKeyOf(seg)
@@ -3168,9 +3509,11 @@
3168
3509
  onclick={() => seekTo(seg.startSec)}
3169
3510
  >
3170
3511
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
3171
- <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3172
- {seg.speakerName ?? 'Unknown speaker'}
3173
- </span>
3512
+ {#if multiSpeaker}
3513
+ <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3514
+ {seg.speakerName ?? 'Unknown speaker'}
3515
+ </span>
3516
+ {/if}
3174
3517
  <span class="vp-row-text">{seg.text}</span>
3175
3518
  </button>
3176
3519
  {/each}
@@ -3180,6 +3523,7 @@
3180
3523
  <button
3181
3524
  type="button"
3182
3525
  class="vp-row"
3526
+ class:vp-row-one={!multiSpeaker}
3183
3527
  class:active={activeSegment?.id === seg.id}
3184
3528
  class:vp-dim={highlightKey != null
3185
3529
  ? highlightKey !== speakerKeyOf(seg)
@@ -3188,9 +3532,11 @@
3188
3532
  onclick={() => seekTo(seg.startSec)}
3189
3533
  >
3190
3534
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
3191
- <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3192
- {seg.speakerName ?? 'Unknown speaker'}
3193
- </span>
3535
+ {#if multiSpeaker}
3536
+ <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3537
+ {seg.speakerName ?? 'Unknown speaker'}
3538
+ </span>
3539
+ {/if}
3194
3540
  <span class="vp-row-text">{seg.text}</span>
3195
3541
  </button>
3196
3542
  {/each}
@@ -3276,6 +3622,244 @@
3276
3622
  }
3277
3623
  }
3278
3624
 
3625
+ /* ── Chapter rail (mos-777hj) ────────────────────────────────────────
3626
+ Opt-in (`chapterRail="side"` + chapters). ≥64rem the pane is a sticky
3627
+ column on the right of the main content (below the stage when the
3628
+ transcript sits below, a third column beside a side transcript);
3629
+ below 64rem the same DOM flows as a horizontal chip strip between the
3630
+ player and the below-stage content — grid areas re-place the panes,
3631
+ the DOM order never changes. Token-only chrome, dot tones resolve
3632
+ DaisyUI semantic vars. */
3633
+ .vp-below-stage {
3634
+ min-width: 0;
3635
+ }
3636
+
3637
+ .vp-chapters {
3638
+ display: flex;
3639
+ flex-direction: column;
3640
+ gap: 1px;
3641
+ min-width: 0;
3642
+ background: var(--color-base-100);
3643
+ border: var(--jatui-border-w, 1px) solid
3644
+ color-mix(in oklch, var(--color-base-content) 12%, transparent);
3645
+ border-radius: var(--jatui-radius-box, 0.5rem);
3646
+ padding: var(--jatui-space-sm, 4px);
3647
+ overflow-y: auto;
3648
+ }
3649
+
3650
+ .vp-chapter-row {
3651
+ position: relative;
3652
+ display: flex;
3653
+ flex-direction: column;
3654
+ flex-shrink: 0;
3655
+ gap: 2px;
3656
+ background: none;
3657
+ border: none;
3658
+ border-radius: var(--jatui-radius-field, 0.25rem);
3659
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-md, 6px);
3660
+ text-align: left;
3661
+ cursor: pointer;
3662
+ transition: background var(--motion-fast, 150ms) var(--motion-glide, ease-out);
3663
+ }
3664
+
3665
+ .vp-chapter-row:hover {
3666
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
3667
+ }
3668
+
3669
+ /* Active = a tinted row plus a primary title; no side stripe. */
3670
+ .vp-chapter-row.active {
3671
+ background: color-mix(in oklch, var(--color-primary) 12%, transparent);
3672
+ }
3673
+
3674
+ .vp-chapter-row.active .vp-chapter-title {
3675
+ color: var(--color-primary);
3676
+ }
3677
+
3678
+ /* listNav's cursor (j/k) — same family as the hover/active surfaces. */
3679
+ .vp-chapter-row.jk-focused {
3680
+ outline: 2px solid color-mix(in oklch, var(--color-primary) 55%, transparent);
3681
+ outline-offset: -2px;
3682
+ }
3683
+
3684
+ .vp-chapter-row.done .vp-chapter-title {
3685
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
3686
+ }
3687
+
3688
+ /* The playhead's chapter reads as current even once its span is played
3689
+ (playback ran past the last tagged line). */
3690
+ .vp-chapter-row.done.active .vp-chapter-title {
3691
+ color: var(--color-primary);
3692
+ }
3693
+
3694
+ .vp-chapter-head {
3695
+ display: flex;
3696
+ align-items: baseline;
3697
+ gap: var(--jatui-space-sm, 4px);
3698
+ min-width: 0;
3699
+ }
3700
+
3701
+ .vp-chapter-dot {
3702
+ width: 0.5em;
3703
+ height: 0.5em;
3704
+ border-radius: var(--jatui-radius-selector, 0.375rem);
3705
+ flex: none;
3706
+ align-self: center;
3707
+ background: var(--color-base-content);
3708
+ opacity: 0.4;
3709
+ }
3710
+
3711
+ .vp-chapter-dot-info {
3712
+ background: var(--color-info);
3713
+ opacity: 1;
3714
+ }
3715
+
3716
+ .vp-chapter-dot-success {
3717
+ background: var(--color-success);
3718
+ opacity: 1;
3719
+ }
3720
+
3721
+ .vp-chapter-dot-warning {
3722
+ background: var(--color-warning);
3723
+ opacity: 1;
3724
+ }
3725
+
3726
+ .vp-chapter-dot-error {
3727
+ background: var(--color-error);
3728
+ opacity: 1;
3729
+ }
3730
+
3731
+ .vp-chapter-time {
3732
+ font-family: ui-monospace, monospace;
3733
+ font-size: 0.66rem;
3734
+ color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
3735
+ flex-shrink: 0;
3736
+ }
3737
+
3738
+ .vp-chapter-title {
3739
+ font-size: 0.76rem;
3740
+ font-weight: 600;
3741
+ color: var(--color-base-content);
3742
+ flex: 1;
3743
+ min-width: 0;
3744
+ overflow: hidden;
3745
+ text-overflow: ellipsis;
3746
+ white-space: nowrap;
3747
+ }
3748
+
3749
+ .vp-chapter-check {
3750
+ color: var(--color-success);
3751
+ flex: none;
3752
+ align-self: center;
3753
+ }
3754
+
3755
+ .vp-chapter-replay {
3756
+ color: color-mix(in oklch, var(--color-primary) 80%, transparent);
3757
+ flex: none;
3758
+ align-self: center;
3759
+ }
3760
+
3761
+ .vp-chapter-summary {
3762
+ font-size: 0.68rem;
3763
+ line-height: 1.35;
3764
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
3765
+ overflow: hidden;
3766
+ display: -webkit-box;
3767
+ -webkit-box-orient: vertical;
3768
+ -webkit-line-clamp: 2;
3769
+ }
3770
+
3771
+ /* The active row's thin fill: transform, not width — a compositor move
3772
+ that never relayouts the row, stepping with the playhead tick. */
3773
+ .vp-chapter-bar {
3774
+ position: absolute;
3775
+ left: var(--jatui-space-md, 6px);
3776
+ right: var(--jatui-space-md, 6px);
3777
+ bottom: 1px;
3778
+ height: 2px;
3779
+ border-radius: 999px;
3780
+ background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
3781
+ overflow: hidden;
3782
+ pointer-events: none;
3783
+ }
3784
+
3785
+ .vp-chapter-fill {
3786
+ display: block;
3787
+ width: 100%;
3788
+ height: 100%;
3789
+ border-radius: inherit;
3790
+ background: var(--color-primary);
3791
+ transform: scaleX(var(--vp-chapter-progress, 0));
3792
+ transform-origin: left center;
3793
+ transition: transform 120ms linear;
3794
+ }
3795
+
3796
+ @media (max-width: 63.999rem) {
3797
+ /* The chip strip: same rows, laid horizontally under the player. */
3798
+ .vp-chapters {
3799
+ flex-direction: row;
3800
+ gap: var(--jatui-space-xs, 2px);
3801
+ overflow-x: auto;
3802
+ overflow-y: hidden;
3803
+ padding: var(--jatui-space-xs, 2px);
3804
+ }
3805
+
3806
+ .vp-chapter-row {
3807
+ flex: none;
3808
+ justify-content: center;
3809
+ max-width: 16rem;
3810
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-sm, 4px);
3811
+ }
3812
+
3813
+ .vp-chapter-summary,
3814
+ .vp-chapter-bar {
3815
+ display: none;
3816
+ }
3817
+
3818
+ .vp-chapter-title {
3819
+ max-width: 12rem;
3820
+ }
3821
+ }
3822
+
3823
+ @media (min-width: 64rem) {
3824
+ .vp-root.vp-rail-side {
3825
+ display: grid;
3826
+ grid-template-columns: minmax(0, 1fr) minmax(13rem, 16rem);
3827
+ grid-template-areas:
3828
+ 'main rail'
3829
+ 'below rail'
3830
+ 'transcript rail';
3831
+ gap: var(--jatui-space-lg, 12px);
3832
+ align-items: start;
3833
+ }
3834
+
3835
+ .vp-root.vp-rail-side > .vp-main {
3836
+ grid-area: main;
3837
+ }
3838
+
3839
+ .vp-root.vp-rail-side > .vp-chapters {
3840
+ grid-area: rail;
3841
+ position: sticky;
3842
+ top: var(--jatui-space-lg, 12px);
3843
+ max-height: calc(100vh - 2 * var(--jatui-space-lg, 12px));
3844
+ }
3845
+
3846
+ .vp-root.vp-rail-side > .vp-below-stage {
3847
+ grid-area: below;
3848
+ }
3849
+
3850
+ .vp-root.vp-rail-side > .vp-transcript {
3851
+ grid-area: transcript;
3852
+ }
3853
+
3854
+ /* Rail beside a side transcript (theater): three columns. */
3855
+ .vp-root.vp-side.vp-rail-side {
3856
+ grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr) minmax(11rem, 14rem);
3857
+ grid-template-areas:
3858
+ 'main transcript rail'
3859
+ 'below transcript rail';
3860
+ }
3861
+ }
3862
+
3279
3863
  .vp-stage {
3280
3864
  position: relative;
3281
3865
  }
@@ -3340,10 +3924,133 @@
3340
3924
  }
3341
3925
 
3342
3926
  .vp-root:hover .vp-band,
3343
- .vp-band:focus-within {
3927
+ .vp-band:focus-within,
3928
+ .vp-band-transport .vp-band {
3929
+ /* With the band transport the band is the ONLY timeline: never ambient. */
3344
3930
  opacity: 1;
3345
3931
  }
3346
3932
 
3933
+ /* Audio: no picture, so no stage at all. The element still plays. */
3934
+ .vp-stage-audio {
3935
+ display: none;
3936
+ }
3937
+
3938
+ .vp-stage-clickable {
3939
+ cursor: pointer;
3940
+ }
3941
+
3942
+ /* The OSD's band seat (audio has no stage to float it over). */
3943
+ .vp-osd-band {
3944
+ top: 0;
3945
+ left: 0;
3946
+ max-width: 100%;
3947
+ overflow: hidden;
3948
+ text-overflow: ellipsis;
3949
+ white-space: nowrap;
3950
+ z-index: 4;
3951
+ }
3952
+
3953
+ /* ── Band transport row ─────────────────────────────────────────────── */
3954
+ .vp-transport {
3955
+ font-size: 0.75rem;
3956
+ }
3957
+
3958
+ .vp-transport-start,
3959
+ .vp-transport-end {
3960
+ display: inline-flex;
3961
+ align-items: center;
3962
+ gap: var(--jatui-space-sm, 4px);
3963
+ min-width: 0;
3964
+ }
3965
+
3966
+ .vp-play {
3967
+ display: inline-grid;
3968
+ place-items: center;
3969
+ width: 2rem;
3970
+ height: 2rem;
3971
+ flex: none;
3972
+ border: none;
3973
+ border-radius: 999px;
3974
+ background: var(--color-primary);
3975
+ color: var(--color-primary-content);
3976
+ cursor: pointer;
3977
+ transition: transform var(--motion-fast, 150ms) var(--motion-glide, ease-out);
3978
+ }
3979
+
3980
+ .vp-play:hover {
3981
+ transform: scale(1.06);
3982
+ }
3983
+
3984
+ .vp-play:active {
3985
+ transform: scale(0.96);
3986
+ }
3987
+
3988
+ .vp-clock {
3989
+ display: inline-flex;
3990
+ gap: 0.3em;
3991
+ margin-left: var(--jatui-space-xs, 2px);
3992
+ white-space: nowrap;
3993
+ }
3994
+
3995
+ .vp-clock-now {
3996
+ color: var(--color-base-content);
3997
+ font-weight: 600;
3998
+ }
3999
+
4000
+ .vp-clock-sep {
4001
+ opacity: 0.5;
4002
+ }
4003
+
4004
+ .vp-tbtn {
4005
+ display: inline-grid;
4006
+ place-items: center;
4007
+ min-width: 1.75rem;
4008
+ height: 1.75rem;
4009
+ padding: 0 var(--jatui-space-xs, 2px);
4010
+ border: none;
4011
+ border-radius: var(--jatui-radius-field, 0.25rem);
4012
+ background: none;
4013
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
4014
+ font-size: inherit;
4015
+ font-variant-numeric: tabular-nums;
4016
+ cursor: pointer;
4017
+ }
4018
+
4019
+ .vp-tbtn:hover {
4020
+ color: var(--color-base-content);
4021
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
4022
+ }
4023
+
4024
+ .vp-play:focus-visible,
4025
+ .vp-tbtn:focus-visible,
4026
+ .vp-volume:focus-visible {
4027
+ outline: 2px solid var(--color-primary);
4028
+ outline-offset: 2px;
4029
+ }
4030
+
4031
+ .vp-speed {
4032
+ min-width: 2.5rem;
4033
+ }
4034
+
4035
+ .vp-volume {
4036
+ width: 4.5rem;
4037
+ accent-color: var(--color-primary);
4038
+ cursor: pointer;
4039
+ }
4040
+
4041
+ /* A fingertip sets volume with the device buttons; the slider is noise.
4042
+ The play button grows to a full 44px target (and stays round). */
4043
+ @media (pointer: coarse) {
4044
+ .vp-volume {
4045
+ display: none;
4046
+ }
4047
+
4048
+ .vp-play {
4049
+ width: 2.75rem;
4050
+ height: 2.75rem;
4051
+ }
4052
+ }
4053
+
3347
4054
  /* One time axis: while the band owns it, the native timeline stands down.
3348
4055
  Only Chromium exposes it; elsewhere this matches nothing and the native
3349
4056
  bar simply stays (see video/band/axis.ts). */
@@ -3563,20 +4270,14 @@
3563
4270
  /* Talk-time + legend */
3564
4271
  .vp-talktime {
3565
4272
  display: flex;
3566
- flex-direction: column;
3567
- gap: var(--jatui-space-sm, 4px);
3568
- }
3569
-
3570
- .vp-talktime-bar {
3571
- display: flex;
3572
- height: 0.6rem;
3573
- border-radius: 999px;
3574
- overflow: hidden;
3575
- background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
4273
+ flex-wrap: wrap;
4274
+ align-items: center;
4275
+ gap: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
3576
4276
  }
3577
4277
 
3578
- .vp-talktime-seg {
3579
- height: 100%;
4278
+ .vp-talktime-label {
4279
+ font-size: 0.68rem;
4280
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
3580
4281
  }
3581
4282
 
3582
4283
  .vp-legend {
@@ -3613,7 +4314,10 @@
3613
4314
  }
3614
4315
 
3615
4316
  .vp-chip-clear {
4317
+ background: none;
3616
4318
  color: var(--color-primary);
4319
+ text-decoration: underline;
4320
+ text-underline-offset: 2px;
3617
4321
  }
3618
4322
 
3619
4323
  .vp-chip-dot {
@@ -3629,18 +4333,31 @@
3629
4333
  }
3630
4334
 
3631
4335
  .vp-mute {
4336
+ display: inline-grid;
4337
+ place-items: center;
4338
+ width: 1.5rem;
4339
+ height: 1.5rem;
3632
4340
  background: none;
3633
4341
  border: none;
3634
- padding: 0 var(--jatui-space-xs, 2px);
3635
- font-size: 0.7rem;
3636
- line-height: 1;
4342
+ border-radius: var(--jatui-radius-field, 0.25rem);
4343
+ padding: 0;
4344
+ color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
3637
4345
  cursor: pointer;
3638
- opacity: 0.5;
3639
4346
  }
3640
4347
 
3641
- .vp-mute.on,
3642
4348
  .vp-mute:hover {
3643
- opacity: 1;
4349
+ color: var(--color-base-content);
4350
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
4351
+ }
4352
+
4353
+ .vp-mute.on {
4354
+ color: var(--color-primary);
4355
+ }
4356
+
4357
+ .vp-mute:focus-visible,
4358
+ .vp-chip:focus-visible {
4359
+ outline: 2px solid var(--color-primary);
4360
+ outline-offset: 1px;
3644
4361
  }
3645
4362
 
3646
4363
  /* Add-bookmark row — only rendered when the host wired onAddBookmark. */
@@ -3704,6 +4421,7 @@
3704
4421
  border-radius: var(--jatui-radius-box, 0.5rem);
3705
4422
  padding: var(--jatui-space-sm, 4px);
3706
4423
  min-width: 0;
4424
+ container: vp-transcript / inline-size;
3707
4425
  }
3708
4426
 
3709
4427
  /* Windowing stand-ins (mos-5lzns.95): fixed-height blocks sized by the
@@ -3714,11 +4432,21 @@
3714
4432
  pointer-events: none;
3715
4433
  }
3716
4434
 
4435
+ /* Rows are a three-column grid (time | speaker | text) so every line's
4436
+ text starts on one edge whatever the speaker's name length; one-voice
4437
+ transcripts drop the speaker column. A narrow pane (container query,
4438
+ not viewport: the player often sits in a narrow column) stacks the
4439
+ speaker above the text. */
3717
4440
  .vp-row {
3718
- display: flex;
4441
+ display: grid;
4442
+ grid-template-columns: 2.75rem minmax(0, 6.5rem) minmax(0, 1fr);
3719
4443
  align-items: baseline;
3720
- flex-wrap: wrap;
3721
- gap: var(--jatui-space-sm, 4px);
4444
+ /* Never shrink inside the capped pane: a host's touch min-height
4445
+ replaces min-height:auto, and the flex column would then squash
4446
+ rows to that floor and overlap their text. */
4447
+ flex-shrink: 0;
4448
+ column-gap: var(--jatui-space-base, 8px);
4449
+ row-gap: 0;
3722
4450
  background: none;
3723
4451
  border: none;
3724
4452
  border-radius: var(--jatui-radius-field, 0.25rem);
@@ -3733,8 +4461,11 @@
3733
4461
  }
3734
4462
 
3735
4463
  .vp-row.active {
3736
- background: color-mix(in oklch, var(--color-primary) 14%, transparent);
3737
- box-shadow: inset 2px 0 0 var(--color-primary);
4464
+ background: color-mix(in oklch, var(--color-primary) 12%, transparent);
4465
+ }
4466
+
4467
+ .vp-row.active .vp-row-time {
4468
+ color: var(--color-primary);
3738
4469
  }
3739
4470
 
3740
4471
  .vp-row-time {
@@ -3747,14 +4478,32 @@
3747
4478
  .vp-row-speaker {
3748
4479
  font-size: 0.72rem;
3749
4480
  font-weight: 600;
3750
- flex-shrink: 0;
4481
+ min-width: 0;
4482
+ overflow: hidden;
4483
+ text-overflow: ellipsis;
4484
+ white-space: nowrap;
3751
4485
  }
3752
4486
 
3753
4487
  .vp-row-text {
3754
- font-size: 0.76rem;
4488
+ font-size: 0.8125rem;
4489
+ line-height: 1.45;
3755
4490
  color: color-mix(in oklch, var(--color-base-content) 85%, transparent);
3756
- flex: 1;
3757
- min-width: 10rem;
4491
+ min-width: 0;
4492
+ }
4493
+
4494
+ .vp-row.vp-row-one {
4495
+ grid-template-columns: 2.75rem minmax(0, 1fr);
4496
+ }
4497
+
4498
+ @container vp-transcript (max-width: 26rem) {
4499
+ .vp-row {
4500
+ grid-template-columns: 2.75rem minmax(0, 1fr);
4501
+ }
4502
+
4503
+ .vp-row-speaker,
4504
+ .vp-row-text {
4505
+ grid-column: 2;
4506
+ }
3758
4507
  }
3759
4508
 
3760
4509
  /* Keyboard help overlay — the `?` surface. Keycaps first, description after;
@@ -3838,9 +4587,17 @@
3838
4587
  .vp-lane-span,
3839
4588
  .vp-chip,
3840
4589
  .vp-add-btn,
3841
- .vp-row {
4590
+ .vp-row,
4591
+ .vp-chapter-row,
4592
+ .vp-chapter-fill,
4593
+ .vp-play {
3842
4594
  transition: none;
3843
4595
  }
4596
+
4597
+ .vp-play:hover,
4598
+ .vp-play:active {
4599
+ transform: none;
4600
+ }
3844
4601
  }
3845
4602
 
3846
4603
  /* ── Night (mos-5lzns.33) ─────────────────────────────────────────────