@joewinke/jatui 0.18.1 → 0.19.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.
@@ -101,9 +101,20 @@
101
101
  sortSegments,
102
102
  sortTurns,
103
103
  speakerKeyOf,
104
- SPEAKER_PALETTE,
104
+ DEFAULT_SPEAKER_SOURCE,
105
105
  UNKNOWN_KEY
106
106
  } from '../video/model';
107
+ import { adjustSpeakerColors, buildSpeakerPaletteSource } from '../video/speakerTheme';
108
+ import {
109
+ buildOffsets,
110
+ EST_ROW_H,
111
+ findAnchor,
112
+ OVERSCAN,
113
+ TRANS_GAP,
114
+ transcriptResetKind,
115
+ VIRTUAL_THRESHOLD,
116
+ windowFor
117
+ } from '../video/transcriptWindow';
107
118
  import {
108
119
  bandClusters,
109
120
  clampCardX,
@@ -724,6 +735,224 @@
724
735
  // mount and only read from inside an effect, never used as a reactive source.
725
736
  let rowRefs: Record<string, HTMLButtonElement> = {};
726
737
 
738
+ // ── Transcript windowing (mos-5lzns.95) ─────────────────────────────────
739
+ // Long transcripts (a 2h talk ≈ 2–3k rows) render only the rows near the
740
+ // viewport; the math lives in video/transcriptWindow.ts. Heights vary
741
+ // (text wraps), so rendered rows are measured and the estimates corrected.
742
+ const transVirtual = $derived(sortedSegments.length > VIRTUAL_THRESHOLD);
743
+ const segIndexById = $derived.by(() => {
744
+ const m = new Map<string, number>();
745
+ sortedSegments.forEach((s, i) => m.set(s.id, i));
746
+ return m;
747
+ });
748
+
749
+ let transcriptEl: HTMLDivElement | null = null;
750
+ let vpScrollTop = $state(0);
751
+ let vpHeight = $state(0);
752
+ let heightsVersion = $state(0);
753
+ // Non-reactive window caches — reset wholesale when the transcript changes.
754
+ let measuredHeights: number[] = [];
755
+ let offsetsCache: number[] = [];
756
+ let offsetsValid = false;
757
+ let transPadTop = 0; // pane padding-top (rows start below it)
758
+ let pendingAutoScrollId: string | null = $state(null);
759
+ let measureScheduled = false;
760
+ const pendingMeasures = new Map<number, HTMLButtonElement>();
761
+
762
+ function ensureOffsets(): number[] {
763
+ if (offsetsValid) return offsetsCache;
764
+ offsetsCache = buildOffsets(measuredHeights, sortedSegments.length, EST_ROW_H, TRANS_GAP);
765
+ offsetsValid = true;
766
+ return offsetsCache;
767
+ }
768
+
769
+ function resetTranscriptWindow() {
770
+ measuredHeights = [];
771
+ offsetsValid = false;
772
+ offsetsCache = [];
773
+ transPadTop = 0;
774
+ pendingAutoScrollId = null;
775
+ vpScrollTop = 0;
776
+ if (transcriptEl) transcriptEl.scrollTop = 0;
777
+ }
778
+
779
+ /** Append-only growth: heights measured for the prefix stay valid, so only
780
+ * the offsets rebuild (lazily, for the new length); scroll position holds. */
781
+ function extendTranscriptWindow() {
782
+ offsetsValid = false;
783
+ }
784
+
785
+ /** Re-index + re-queue measurement for every mounted row. A transcript
786
+ * swap can keep keyed rows mounted (same seg.id) while their indices and
787
+ * the height cache change — without this, dataset.segIndex goes stale and
788
+ * the spacers drift. */
789
+ function requeueMountedRows() {
790
+ for (const [id, node] of Object.entries(rowRefs)) {
791
+ const idx = segIndexById.get(id);
792
+ if (idx == null) continue; // row unmounts; its action destroy cleans up
793
+ node.dataset.segIndex = String(idx);
794
+ if (transVirtual) pendingMeasures.set(idx, node);
795
+ }
796
+ scheduleRowMeasure();
797
+ }
798
+
799
+ let prevSegmentIds: string[] | null = null;
800
+
801
+ $effect(() => {
802
+ // New transcript data ⇒ classify against the ids already rendered:
803
+ // unchanged ⇒ nothing (rows are keyed and stay mounted); append-only ⇒
804
+ // keep scrollTop and the measured prefix; anything else ⇒ full reset.
805
+ void transVirtual;
806
+ const ids = sortedSegments.map((s) => s.id);
807
+ untrack(() => {
808
+ const prev = prevSegmentIds;
809
+ prevSegmentIds = ids;
810
+ const kind = prev === null ? 'reset' : transcriptResetKind(prev, ids);
811
+ if (kind === 'reset') {
812
+ resetTranscriptWindow();
813
+ requeueMountedRows();
814
+ } else if (kind === 'extend') {
815
+ extendTranscriptWindow();
816
+ }
817
+ });
818
+ });
819
+
820
+ const transWindow = $derived.by(() => {
821
+ if (!transVirtual) return null;
822
+ void vpScrollTop;
823
+ void vpHeight;
824
+ void heightsVersion;
825
+ const offsets = ensureOffsets();
826
+ const w = windowFor(offsets, vpScrollTop - transPadTop, Math.max(0, vpHeight), OVERSCAN);
827
+ // Spacer heights account for the flex gap that separates them from the
828
+ // rows: the top spacer is followed by a gap, and the pane's last row has
829
+ // no trailing gap, so the total matches the unwindowed layout exactly.
830
+ const total = offsets[sortedSegments.length] ?? 0;
831
+ return {
832
+ ...w,
833
+ padTop: Math.max(0, (offsets[w.first] ?? 0) - TRANS_GAP),
834
+ padBottom: Math.max(0, total - TRANS_GAP - (offsets[w.last] ?? total))
835
+ };
836
+ });
837
+
838
+ const renderedSegments = $derived(
839
+ transWindow ? sortedSegments.slice(transWindow.first, transWindow.last) : sortedSegments
840
+ );
841
+
842
+ function onTranscriptScroll() {
843
+ if (transcriptEl) vpScrollTop = transcriptEl.scrollTop;
844
+ }
845
+
846
+ function bindTranscriptRow(seg: VideoTranscriptSegment, node: HTMLButtonElement) {
847
+ rowRefs[seg.id] = node;
848
+ if (!transVirtual) return;
849
+ const idx = segIndexById.get(seg.id);
850
+ if (idx == null) return;
851
+ node.dataset.segIndex = String(idx);
852
+ pendingMeasures.set(idx, node);
853
+ scheduleRowMeasure();
854
+ }
855
+
856
+ /** Action (node, param): bind the row, measure it, keep it correct across
857
+ * transcript updates, and clean the ref up on unmount. */
858
+ function transcriptRowAction(node: HTMLButtonElement, seg: VideoTranscriptSegment) {
859
+ bindTranscriptRow(seg, node);
860
+ return {
861
+ update(next: VideoTranscriptSegment) {
862
+ if (next.id !== seg.id) delete rowRefs[seg.id];
863
+ seg = next;
864
+ // Keyed rows survive transcript rebuilds: re-index the dataset and
865
+ // re-queue measurement so the height cache tracks the new array.
866
+ bindTranscriptRow(seg, node);
867
+ },
868
+ destroy() {
869
+ delete rowRefs[seg.id];
870
+ // Windowing unmount of the FOCUSED row: move focus to the pane
871
+ // container instead of dropping it to <body> (keyboard users keep
872
+ // their place; scrolling brings the rows back into range).
873
+ const idx = segIndexById.get(seg.id);
874
+ const outOfWindow =
875
+ idx == null ||
876
+ transWindow === null ||
877
+ idx < transWindow.first ||
878
+ idx >= transWindow.last;
879
+ if (
880
+ transVirtual &&
881
+ transWindow !== null &&
882
+ outOfWindow &&
883
+ transcriptEl &&
884
+ document.activeElement === node
885
+ ) {
886
+ transcriptEl.focus();
887
+ }
888
+ }
889
+ };
890
+ }
891
+
892
+ function scheduleRowMeasure() {
893
+ if (measureScheduled) return;
894
+ measureScheduled = true;
895
+ requestAnimationFrame(applyRowMeasures);
896
+ }
897
+
898
+ function applyRowMeasures() {
899
+ measureScheduled = false;
900
+ if (pendingMeasures.size === 0) return;
901
+ const el = transcriptEl;
902
+ const before = ensureOffsets();
903
+ const st = el ? el.scrollTop : vpScrollTop;
904
+ const anchor = findAnchor(before, st);
905
+ const beforeTop = before[anchor] ?? 0;
906
+ let changed = false;
907
+ for (const [idx, node] of pendingMeasures) {
908
+ const h = node.offsetHeight;
909
+ if (h > 0 && measuredHeights[idx] !== h) {
910
+ measuredHeights[idx] = h;
911
+ changed = true;
912
+ }
913
+ }
914
+ pendingMeasures.clear();
915
+ if (!changed) return;
916
+ offsetsValid = false;
917
+ heightsVersion++;
918
+ // Keep the viewport content stable: if rows ABOVE the scroll position
919
+ // changed height, shift scrollTop by the same delta so nothing jumps.
920
+ const after = ensureOffsets();
921
+ const delta = (after[anchor] ?? beforeTop) - beforeTop;
922
+ if (el && delta !== 0 && st >= beforeTop) {
923
+ el.scrollTop = st + delta; // fires onscroll → vpScrollTop follows
924
+ }
925
+ }
926
+
927
+ function measureTranscriptPane(el: HTMLDivElement) {
928
+ transcriptEl = el;
929
+ el.tabIndex = -1; // focus sink when the focused row unmounts from windowing
930
+ const cs = getComputedStyle(el);
931
+ transPadTop = parseFloat(cs.paddingTop) || 0;
932
+ vpHeight = el.clientHeight;
933
+ const ro = new ResizeObserver(() => {
934
+ vpHeight = el.clientHeight;
935
+ // Width changes re-wrap text: re-measure what's rendered.
936
+ for (const node of Object.values(rowRefs)) {
937
+ const idx = Number(node.dataset.segIndex);
938
+ if (Number.isInteger(idx) && idx >= 0) pendingMeasures.set(idx, node);
939
+ }
940
+ scheduleRowMeasure();
941
+ });
942
+ ro.observe(el);
943
+ return {
944
+ destroy() {
945
+ ro.disconnect();
946
+ transcriptEl = null;
947
+ }
948
+ };
949
+ }
950
+
951
+ /** Action wrapper so the template can attach the pane. */
952
+ function transcriptPaneAction(el: HTMLDivElement) {
953
+ return measureTranscriptPane(el);
954
+ }
955
+
727
956
  // ── Derived data ────────────────────────────────────────────────────────
728
957
  const effectiveDuration = $derived(
729
958
  Number.isFinite(duration) && duration > 0 ? duration : (waveform?.durationSec ?? 0)
@@ -760,8 +989,34 @@
760
989
 
761
990
  const hasSpeakers = $derived(speakerIndex.size > 0);
762
991
 
763
- /** key → identity colour, resolved ONCE per data change (see video/model.ts). */
764
- const speakerColors = $derived(buildSpeakerColors(speakerIndex));
992
+ /**
993
+ * Theme-derived speaker identity: colours + the fallback cycle in one read
994
+ * (mos-5lzns.95). Colours derive from the live theme's tokens: the cycle
995
+ * rotates the semantic role colours and hue-bearing speakers take the
996
+ * theme's own lightness/chroma, then every colour is lifted to AA against
997
+ * the transcript surface by mixing toward base-content. SSR keeps the
998
+ * legacy literal palette (deterministic snapshot output); themeTick
999
+ * recomputes everything on a theme swap.
1000
+ */
1001
+ const speakerTheme = $derived.by(() => {
1002
+ void themeTick;
1003
+ if (typeof document === 'undefined') {
1004
+ return {
1005
+ colors: buildSpeakerColors(speakerIndex, DEFAULT_SPEAKER_SOURCE),
1006
+ cycle: DEFAULT_SPEAKER_SOURCE.cycle
1007
+ };
1008
+ }
1009
+ const ctx = buildSpeakerPaletteSource();
1010
+ return {
1011
+ colors: adjustSpeakerColors(
1012
+ buildSpeakerColors(speakerIndex, ctx.source),
1013
+ ctx.surface,
1014
+ ctx.ink
1015
+ ),
1016
+ cycle: ctx.source.cycle
1017
+ };
1018
+ });
1019
+ const speakerColors = $derived(speakerTheme.colors);
765
1020
 
766
1021
  /** With an envelope the bars carry the hues; the lane is the no-waveform fallback only. */
767
1022
  const laneCarriesSpeakers = $derived(!hasWave && sortedTurns.length > 0);
@@ -863,7 +1118,7 @@
863
1118
 
864
1119
  /** Explicit hue wins (host-supplied identity colour); otherwise the cycled palette. */
865
1120
  function speakerColor(key: string): string {
866
- return speakerColors.get(key) ?? SPEAKER_PALETTE[0];
1121
+ return speakerColors.get(key) ?? speakerTheme.cycle[0] ?? DEFAULT_SPEAKER_SOURCE.cycle[0];
867
1122
  }
868
1123
 
869
1124
  // ── Zoom window: one mapping every position computation goes through ────
@@ -2471,10 +2726,39 @@
2471
2726
  });
2472
2727
 
2473
2728
  // Auto-scroll the active transcript row into view as playback advances.
2729
+ // Windowed panes may not have the row rendered: jump to its (estimated)
2730
+ // offset first, then a follow-up effect lands exactly on the real row once
2731
+ // the window renders it.
2474
2732
  $effect(() => {
2475
2733
  const seg = activeSegment;
2476
2734
  if (!seg) return;
2477
- untrack(() => rowRefs[seg.id]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
2735
+ untrack(() => {
2736
+ const node = rowRefs[seg.id];
2737
+ if (node) {
2738
+ node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2739
+ return;
2740
+ }
2741
+ if (!transVirtual || !transcriptEl) return;
2742
+ const idx = segIndexById.get(seg.id);
2743
+ if (idx == null) return;
2744
+ const top = ensureOffsets()[idx] ?? 0;
2745
+ // Row top (in content coords) = pane padding + offset; land it ⅓ down.
2746
+ transcriptEl.scrollTop = Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3);
2747
+ pendingAutoScrollId = seg.id;
2748
+ });
2749
+ });
2750
+
2751
+ // Exact landing after a windowed jump: the row is now rendered — snap to it.
2752
+ $effect(() => {
2753
+ void renderedSegments;
2754
+ const id = pendingAutoScrollId;
2755
+ if (!id) return;
2756
+ untrack(() => {
2757
+ const node = rowRefs[id];
2758
+ if (!node) return;
2759
+ pendingAutoScrollId = null;
2760
+ node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2761
+ });
2478
2762
  });
2479
2763
 
2480
2764
  // ── Legend interactions ─────────────────────────────────────────────────
@@ -2861,25 +3145,56 @@
2861
3145
  </div>
2862
3146
 
2863
3147
  {#if sortedSegments.length > 0 && transcriptVisible}
2864
- <div class="vp-transcript" style="max-height: {transcriptMaxHeight}">
2865
- {#each sortedSegments as seg (seg.id)}
2866
- <button
2867
- type="button"
2868
- class="vp-row"
2869
- class:active={activeSegment?.id === seg.id}
2870
- class:vp-dim={highlightKey != null
2871
- ? highlightKey !== speakerKeyOf(seg)
2872
- : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
2873
- bind:this={rowRefs[seg.id]}
2874
- onclick={() => seekTo(seg.startSec)}
2875
- >
2876
- <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
2877
- <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
2878
- {seg.speakerName ?? 'Unknown speaker'}
2879
- </span>
2880
- <span class="vp-row-text">{seg.text}</span>
2881
- </button>
2882
- {/each}
3148
+ <div
3149
+ class="vp-transcript"
3150
+ style="max-height: {transcriptMaxHeight}"
3151
+ use:transcriptPaneAction
3152
+ onscroll={onTranscriptScroll}
3153
+ >
3154
+ {#if transWindow}
3155
+ <!-- Windowed: only the rows near the viewport exist in the DOM.
3156
+ Spacers stand in for the rows above/below; heights are
3157
+ estimates corrected by measuring rendered rows. -->
3158
+ <div class="vp-spacer" style="height: {transWindow.padTop}px" aria-hidden="true"></div>
3159
+ {#each renderedSegments as seg (seg.id)}
3160
+ <button
3161
+ type="button"
3162
+ class="vp-row"
3163
+ class:active={activeSegment?.id === seg.id}
3164
+ class:vp-dim={highlightKey != null
3165
+ ? highlightKey !== speakerKeyOf(seg)
3166
+ : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
3167
+ use:transcriptRowAction={seg}
3168
+ onclick={() => seekTo(seg.startSec)}
3169
+ >
3170
+ <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>
3174
+ <span class="vp-row-text">{seg.text}</span>
3175
+ </button>
3176
+ {/each}
3177
+ <div class="vp-spacer" style="height: {transWindow.padBottom}px" aria-hidden="true"></div>
3178
+ {:else}
3179
+ {#each sortedSegments as seg (seg.id)}
3180
+ <button
3181
+ type="button"
3182
+ class="vp-row"
3183
+ class:active={activeSegment?.id === seg.id}
3184
+ class:vp-dim={highlightKey != null
3185
+ ? highlightKey !== speakerKeyOf(seg)
3186
+ : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
3187
+ use:transcriptRowAction={seg}
3188
+ onclick={() => seekTo(seg.startSec)}
3189
+ >
3190
+ <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>
3194
+ <span class="vp-row-text">{seg.text}</span>
3195
+ </button>
3196
+ {/each}
3197
+ {/if}
2883
3198
  </div>
2884
3199
  {/if}
2885
3200
 
@@ -3391,6 +3706,14 @@
3391
3706
  min-width: 0;
3392
3707
  }
3393
3708
 
3709
+ /* Windowing stand-ins (mos-5lzns.95): fixed-height blocks sized by the
3710
+ prefix-sum offsets so the scrollbar reflects the whole transcript. */
3711
+ .vp-spacer {
3712
+ flex: none;
3713
+ width: 100%;
3714
+ pointer-events: none;
3715
+ }
3716
+
3394
3717
  .vp-row {
3395
3718
  display: flex;
3396
3719
  align-items: baseline;
@@ -10,10 +10,42 @@ import type { VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment } f
10
10
  export declare const ALL_KEY = "__all__";
11
11
  export declare const UNKNOWN_KEY = "__unknown__";
12
12
  /**
13
- * Speaker identity palette — the same small distinct oklch set TranscriptPlayer
14
- * cycles, so a speaker reads as the same colour in both components.
13
+ * Speaker identity palette — the FALLBACK cycle used only where no theme is
14
+ * reachable (SSR render, unit tests, un-styled hosts). Live components derive
15
+ * the cycle from DaisyUI theme tokens via buildSpeakerPaletteSource()
16
+ * (video/speakerTheme.ts) so white-label tenants reskin speaker colours with
17
+ * the rest of the theme (mos-5lzns.95).
15
18
  */
16
19
  export declare const SPEAKER_PALETTE: string[];
20
+ /**
21
+ * Where identity colours come from. `cycle` is the rotation for speakers
22
+ * without an explicit diarization hue; `hueBase` (lightness + chroma from a
23
+ * THEME token, usually --color-primary) is rebuilt into `oklch(L C hue)` so
24
+ * hue-bearing speakers follow the theme's own tone instead of a literal.
25
+ * `hueBase: null` falls those speakers back onto the cycle.
26
+ */
27
+ export interface SpeakerColorSource {
28
+ cycle: string[];
29
+ hueBase: {
30
+ l: number;
31
+ c: number;
32
+ } | null;
33
+ }
34
+ /**
35
+ * The legacy derivation, kept as the default so SSR and pure consumers render
36
+ * exactly as before (stable snapshot output, no DOM needed).
37
+ */
38
+ export declare const DEFAULT_SPEAKER_SOURCE: SpeakerColorSource;
39
+ /**
40
+ * Parse `oklch(L C H)` / `oklch(L% C H)` / with alpha into just the lightness
41
+ * and chroma channels (hue comes from the speaker, not the theme). Returns
42
+ * null for anything that isn't an oklch() literal — hex themes just fall back
43
+ * to the cycle.
44
+ */
45
+ export declare function parseOklch(color: string): {
46
+ l: number;
47
+ c: number;
48
+ } | null;
17
49
  export type SpeakerLike = {
18
50
  speakerIdentity?: string | null;
19
51
  speakerName?: string | null;
@@ -37,7 +69,7 @@ export declare function buildSpeakerColors(speakerIndex: Map<string, {
37
69
  key: string;
38
70
  name: string;
39
71
  hue: number | null;
40
- }>): Map<string, string>;
72
+ }>, src?: SpeakerColorSource): Map<string, string>;
41
73
  /** Ascending sort of the optional data arrays — the only normalization every consumer agrees on. */
42
74
  export declare function sortChapters<T extends {
43
75
  startSec: number;
@@ -1,8 +1,11 @@
1
1
  export const ALL_KEY = '__all__';
2
2
  export const UNKNOWN_KEY = '__unknown__';
3
3
  /**
4
- * Speaker identity palette — the same small distinct oklch set TranscriptPlayer
5
- * cycles, so a speaker reads as the same colour in both components.
4
+ * Speaker identity palette — the FALLBACK cycle used only where no theme is
5
+ * reachable (SSR render, unit tests, un-styled hosts). Live components derive
6
+ * the cycle from DaisyUI theme tokens via buildSpeakerPaletteSource()
7
+ * (video/speakerTheme.ts) so white-label tenants reskin speaker colours with
8
+ * the rest of the theme (mos-5lzns.95).
6
9
  */
7
10
  export const SPEAKER_PALETTE = [
8
11
  'oklch(0.70 0.16 250)', // blue
@@ -12,6 +15,30 @@ export const SPEAKER_PALETTE = [
12
15
  'oklch(0.68 0.16 300)', // violet
13
16
  'oklch(0.72 0.14 200)' // cyan
14
17
  ];
18
+ /**
19
+ * The legacy derivation, kept as the default so SSR and pure consumers render
20
+ * exactly as before (stable snapshot output, no DOM needed).
21
+ */
22
+ export const DEFAULT_SPEAKER_SOURCE = {
23
+ cycle: SPEAKER_PALETTE,
24
+ hueBase: { l: 0.72, c: 0.14 }
25
+ };
26
+ /**
27
+ * Parse `oklch(L C H)` / `oklch(L% C H)` / with alpha into just the lightness
28
+ * and chroma channels (hue comes from the speaker, not the theme). Returns
29
+ * null for anything that isn't an oklch() literal — hex themes just fall back
30
+ * to the cycle.
31
+ */
32
+ export function parseOklch(color) {
33
+ const m = /^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+[\d.]+(?:\s*\/\s*[\d.]+%?\s*)?\)$/i.exec(color.trim());
34
+ if (!m)
35
+ return null;
36
+ const l = parseFloat(m[1]);
37
+ const c = parseFloat(m[2]);
38
+ if (!Number.isFinite(l) || !Number.isFinite(c))
39
+ return null;
40
+ return { l: /%/.test(m[1]) ? l / 100 : l, c };
41
+ }
15
42
  export function speakerKeyOf(s) {
16
43
  return s.speakerIdentity ?? s.speakerName ?? UNKNOWN_KEY;
17
44
  }
@@ -45,17 +72,25 @@ export function buildSpeakerIndex(turns, segments) {
45
72
  * colour per rendered column and reruns on every playhead tick, so this must
46
73
  * never be an indexOf scan at call time.
47
74
  */
48
- export function buildSpeakerColors(speakerIndex) {
75
+ export function buildSpeakerColors(speakerIndex, src = DEFAULT_SPEAKER_SOURCE) {
49
76
  const map = new Map();
77
+ const cycle = src.cycle.length > 0 ? src.cycle : DEFAULT_SPEAKER_SOURCE.cycle;
50
78
  let i = 0;
51
79
  for (const [key, entry] of speakerIndex) {
52
80
  map.set(key, entry.hue != null
53
- ? `oklch(0.72 0.14 ${entry.hue})`
54
- : SPEAKER_PALETTE[i % SPEAKER_PALETTE.length]);
81
+ ? hueColor(entry.hue, src.hueBase) ?? cycle[i % cycle.length]
82
+ : cycle[i % cycle.length]);
55
83
  i++;
56
84
  }
57
85
  return map;
58
86
  }
87
+ /** Theme-toned identity colour for a diarization hue, wrapped to 0–360. */
88
+ function hueColor(hue, base) {
89
+ if (!base)
90
+ return null;
91
+ const h = ((hue % 360) + 360) % 360;
92
+ return `oklch(${base.l} ${base.c} ${h})`;
93
+ }
59
94
  /** Ascending sort of the optional data arrays — the only normalization every consumer agrees on. */
60
95
  export function sortChapters(chapters) {
61
96
  return [...chapters]
@@ -0,0 +1,43 @@
1
+ import { type SpeakerColorSource } from './model';
2
+ /**
3
+ * hueBase from a theme token colour. Keeps the token's lightness, floors its
4
+ * chroma, and falls back to the legacy 0.72/0.14 tone when the token is
5
+ * achromatic (or not an oklch literal — parseOklch returns null for hex
6
+ * themes, which then ride the cycle only).
7
+ */
8
+ export declare function hueBaseFromToken(color: string | null | undefined): {
9
+ l: number;
10
+ c: number;
11
+ } | null;
12
+ export interface SpeakerThemeContext {
13
+ source: SpeakerColorSource;
14
+ /** Resolved --color-base-100 (the transcript surface). */
15
+ surface: string;
16
+ /** Resolved --color-base-content (the ink the ladder mixes toward). */
17
+ ink: string;
18
+ }
19
+ /**
20
+ * Build the palette source from the live theme. De-dupes the token colours
21
+ * (a custom theme may alias roles) and tops the cycle up with token mixes if
22
+ * fewer than three distinct hues survive, so speakers stay distinguishable.
23
+ */
24
+ export declare function buildSpeakerPaletteSource(): SpeakerThemeContext;
25
+ /** `color-mix(in oklch, color (100−pct)%, ink pct%)` — pct is the INK weight. */
26
+ export declare function mixToward(color: string, ink: string, pct: number): string;
27
+ /** WCAG contrast ratio between two rgb triples (≥1, ≤21). */
28
+ export declare function contrastRatio(a: readonly number[], b: readonly number[]): number;
29
+ /**
30
+ * Resolve any CSS colour string to sRGB via a 1×1 canvas. Returns null when
31
+ * the string cannot be parsed (SSR, unsupported colour function) — callers
32
+ * keep the original colour rather than guessing.
33
+ */
34
+ export declare function resolveColorRgb(color: string): readonly [number, number, number] | null;
35
+ /**
36
+ * Lift `color` to AA contrast against `surface` by ladder-mixing toward ink.
37
+ * Colour-parsing environments that cannot resolve a string keep it unchanged
38
+ * (the raw token value is still theme-following, just unverified).
39
+ * `resolve` is injectable for DOM-free tests.
40
+ */
41
+ export declare function adjustForContrast(color: string, surface: string, ink: string, minRatio?: number, resolve?: (c: string) => readonly [number, number, number] | null): string;
42
+ /** Map-of-speakers convenience over adjustForContrast. */
43
+ export declare function adjustSpeakerColors(colors: Map<string, string>, surface: string, ink: string): Map<string, string>;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Theme-derived speaker identity colours for VideoPlayer (mos-5lzns.95).
3
+ *
4
+ * The old SPEAKER_PALETTE was hard-coded oklch literals, so white-label
5
+ * tenants could not reskin speaker colours. This module derives the cycle
6
+ * from the ACTIVE DaisyUI theme's semantic tokens (the same six roles most
7
+ * themes give distinct hues), rebuilds hue-bearing speaker colours on the
8
+ * theme's own lightness/chroma, and lifts every identity colour to WCAG AA
9
+ * against the surface by mixing toward `--color-base-content` when needed —
10
+ * one colour per speaker that works as band fill, chip dot AND transcript
11
+ * text.
12
+ *
13
+ * DOM-aware (computed styles + a 1×1 canvas), client-only by contract — the
14
+ * component calls it under `typeof document !== 'undefined'` and falls back
15
+ * to DEFAULT_SPEAKER_SOURCE for SSR.
16
+ */
17
+ import { getDaisyUIColor } from '../utils/daisyuiColors';
18
+ import { DEFAULT_SPEAKER_SOURCE, parseOklch } from './model';
19
+ import { relativeLuminance } from './night/levels';
20
+ /** Semantic tokens the cycle rotates, in rotation order. */
21
+ const CYCLE_TOKENS = ['primary', 'secondary', 'accent', 'info', 'success', 'warning'];
22
+ /** Mixing steps toward base-content tried (in order) until AA is met. */
23
+ const MIX_LADDER = [25, 45, 65, 85];
24
+ /** Chroma below which a token is effectively achromatic (lofi/black themes
25
+ * resolve primary at ≈0 chroma, which would render every hue speaker the same
26
+ * grey) — those fall back to the legacy speaker tone instead. */
27
+ const ACHROMATIC_CHROMA = 0.03;
28
+ /** Floor for the hue-bearing base chroma: near-neutral tokens are lifted so
29
+ * hue speakers stay distinguishable from each other. */
30
+ const HUE_CHROMA_FLOOR = 0.1;
31
+ /**
32
+ * hueBase from a theme token colour. Keeps the token's lightness, floors its
33
+ * chroma, and falls back to the legacy 0.72/0.14 tone when the token is
34
+ * achromatic (or not an oklch literal — parseOklch returns null for hex
35
+ * themes, which then ride the cycle only).
36
+ */
37
+ export function hueBaseFromToken(color) {
38
+ const parsed = color ? parseOklch(color) : null;
39
+ if (!parsed)
40
+ return null;
41
+ if (parsed.c < ACHROMATIC_CHROMA)
42
+ return DEFAULT_SPEAKER_SOURCE.hueBase;
43
+ return { l: parsed.l, c: Math.max(parsed.c, HUE_CHROMA_FLOOR) };
44
+ }
45
+ /** WCAG AA for body-size text. */
46
+ const AA_CONTRAST = 4.5;
47
+ /**
48
+ * Build the palette source from the live theme. De-dupes the token colours
49
+ * (a custom theme may alias roles) and tops the cycle up with token mixes if
50
+ * fewer than three distinct hues survive, so speakers stay distinguishable.
51
+ */
52
+ export function buildSpeakerPaletteSource() {
53
+ const surface = getDaisyUIColor('base-100');
54
+ const ink = getDaisyUIColor('base-content');
55
+ const seen = new Set();
56
+ const cycle = [];
57
+ for (const token of CYCLE_TOKENS) {
58
+ const c = getDaisyUIColor(token);
59
+ if (c && !seen.has(c)) {
60
+ seen.add(c);
61
+ cycle.push(c);
62
+ }
63
+ }
64
+ if (cycle.length > 0 && cycle.length < 3) {
65
+ const base = cycle[0];
66
+ for (const pct of [30, 55, 80]) {
67
+ const m = mixToward(base, ink, pct);
68
+ if (!seen.has(m)) {
69
+ seen.add(m);
70
+ cycle.push(m);
71
+ }
72
+ }
73
+ }
74
+ return {
75
+ source: {
76
+ cycle: cycle.length > 0 ? cycle : ['oklch(0.5 0 0)'],
77
+ hueBase: hueBaseFromToken(getDaisyUIColor('primary'))
78
+ },
79
+ surface,
80
+ ink
81
+ };
82
+ }
83
+ /** `color-mix(in oklch, color (100−pct)%, ink pct%)` — pct is the INK weight. */
84
+ export function mixToward(color, ink, pct) {
85
+ return `color-mix(in oklch, ${color} ${100 - pct}%, ${ink} ${pct}%)`;
86
+ }
87
+ /** WCAG contrast ratio between two rgb triples (≥1, ≤21). */
88
+ export function contrastRatio(a, b) {
89
+ const la = relativeLuminance(a[0], a[1], a[2]);
90
+ const lb = relativeLuminance(b[0], b[1], b[2]);
91
+ const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
92
+ return (hi + 0.05) / (lo + 0.05);
93
+ }
94
+ const rgbCache = new Map();
95
+ /**
96
+ * Resolve any CSS colour string to sRGB via a 1×1 canvas. Returns null when
97
+ * the string cannot be parsed (SSR, unsupported colour function) — callers
98
+ * keep the original colour rather than guessing.
99
+ */
100
+ export function resolveColorRgb(color) {
101
+ if (typeof document === 'undefined')
102
+ return null;
103
+ const cached = rgbCache.get(color);
104
+ if (cached !== undefined)
105
+ return cached;
106
+ let out = null;
107
+ try {
108
+ const canvas = document.createElement('canvas');
109
+ canvas.width = canvas.height = 1;
110
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
111
+ if (ctx) {
112
+ ctx.fillStyle = '#123456'; // known-good sentinel
113
+ const sentinel = ctx.fillStyle;
114
+ ctx.fillStyle = color;
115
+ if (ctx.fillStyle !== sentinel) {
116
+ ctx.fillRect(0, 0, 1, 1);
117
+ const d = ctx.getImageData(0, 0, 1, 1).data;
118
+ out = [d[0], d[1], d[2]];
119
+ }
120
+ }
121
+ }
122
+ catch {
123
+ out = null;
124
+ }
125
+ rgbCache.set(color, out);
126
+ return out;
127
+ }
128
+ /**
129
+ * Lift `color` to AA contrast against `surface` by ladder-mixing toward ink.
130
+ * Colour-parsing environments that cannot resolve a string keep it unchanged
131
+ * (the raw token value is still theme-following, just unverified).
132
+ * `resolve` is injectable for DOM-free tests.
133
+ */
134
+ export function adjustForContrast(color, surface, ink, minRatio = AA_CONTRAST, resolve = resolveColorRgb) {
135
+ const s = resolve(surface);
136
+ const c = resolve(color);
137
+ if (!s || !c || contrastRatio(c, s) >= minRatio)
138
+ return color;
139
+ const i = resolve(ink);
140
+ if (!i)
141
+ return color;
142
+ for (const pct of MIX_LADDER) {
143
+ const mixed = mixToward(color, ink, pct);
144
+ const m = resolve(mixed);
145
+ if (m && contrastRatio(m, s) >= minRatio)
146
+ return mixed;
147
+ }
148
+ return mixToward(color, ink, 95);
149
+ }
150
+ /** Map-of-speakers convenience over adjustForContrast. */
151
+ export function adjustSpeakerColors(colors, surface, ink) {
152
+ const out = new Map();
153
+ for (const [key, color] of colors)
154
+ out.set(key, adjustForContrast(color, surface, ink));
155
+ return out;
156
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Transcript windowing for VideoPlayer (mos-5lzns.95).
3
+ *
4
+ * Pure, DOM-free math: a transcript pane renders only the rows near the
5
+ * viewport instead of every segment (a 2h talk is 2–3k rows). The component
6
+ * keeps the measured row heights (index → px, sparse) and a scroll offset;
7
+ * this module owns the prefix-sum offsets, the binary-search window and the
8
+ * constants. Row heights vary (text wraps), so estimates are corrected by
9
+ * measurement — the offsets are always exact for measured rows.
10
+ */
11
+ /** Below this many segments the pane renders everything (no windowing). */
12
+ export declare const VIRTUAL_THRESHOLD = 120;
13
+ /** Rows rendered above and below the visible window. */
14
+ export declare const OVERSCAN = 8;
15
+ /** Height assumed for a row until it has been measured (px). */
16
+ export declare const EST_ROW_H = 30;
17
+ /** Flex gap between transcript rows — MUST match .vp-transcript's CSS gap. */
18
+ export declare const TRANS_GAP = 1;
19
+ /**
20
+ * Prefix sums over row heights: offsets[0] = 0, offsets[i] = top edge of row i
21
+ * relative to the pane's content start (its padding box), including `gap` px
22
+ * of flex gap before each row. Unmeasured rows use `fallback`. `heights` may
23
+ * be sparse.
24
+ */
25
+ export declare function buildOffsets(heights: ArrayLike<number>, count: number, fallback: number, gap?: number): number[];
26
+ /** Largest row index whose TOP edge is at or above y (0 when y ≤ 0). */
27
+ export declare function findAnchor(offsets: ArrayLike<number>, y: number): number;
28
+ /** How a new segment-id list relates to the one currently rendered. */
29
+ export type TranscriptResetKind = 'none' | 'extend' | 'reset';
30
+ /**
31
+ * Classify a transcript update against the id list already rendered (both in
32
+ * render order):
33
+ *
34
+ * - `'none'` — identical ids in order: keyed rows stay mounted and everything
35
+ * holds (scroll position, measured heights, offsets). No reset.
36
+ * - `'extend'` — append-only growth: the prefix keeps every measured height
37
+ * and the scroll position holds; offsets rebuild lazily for the new length.
38
+ * - `'reset'` — anything else (replacement, reorder, removal): fresh height
39
+ * cache and top-of-pane scroll.
40
+ */
41
+ export declare function transcriptResetKind(prev: readonly string[], next: readonly string[]): TranscriptResetKind;
42
+ /**
43
+ * The row range to render for a scroll position. `first`/`last` are inclusive
44
+ * row indexes padded by `overscan`; every row overlapping the viewport is in
45
+ * range. Returns null-shaped output when the pane can't be measured yet — the
46
+ * component treats a non-positive viewport as "render the overscan around the
47
+ * top" rather than nothing.
48
+ */
49
+ export declare function windowFor(offsets: ArrayLike<number>, scrollTop: number, viewportH: number, overscan: number): {
50
+ first: number;
51
+ last: number;
52
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Transcript windowing for VideoPlayer (mos-5lzns.95).
3
+ *
4
+ * Pure, DOM-free math: a transcript pane renders only the rows near the
5
+ * viewport instead of every segment (a 2h talk is 2–3k rows). The component
6
+ * keeps the measured row heights (index → px, sparse) and a scroll offset;
7
+ * this module owns the prefix-sum offsets, the binary-search window and the
8
+ * constants. Row heights vary (text wraps), so estimates are corrected by
9
+ * measurement — the offsets are always exact for measured rows.
10
+ */
11
+ /** Below this many segments the pane renders everything (no windowing). */
12
+ export const VIRTUAL_THRESHOLD = 120;
13
+ /** Rows rendered above and below the visible window. */
14
+ export const OVERSCAN = 8;
15
+ /** Height assumed for a row until it has been measured (px). */
16
+ export const EST_ROW_H = 30;
17
+ /** Flex gap between transcript rows — MUST match .vp-transcript's CSS gap. */
18
+ export const TRANS_GAP = 1;
19
+ /**
20
+ * Prefix sums over row heights: offsets[0] = 0, offsets[i] = top edge of row i
21
+ * relative to the pane's content start (its padding box), including `gap` px
22
+ * of flex gap before each row. Unmeasured rows use `fallback`. `heights` may
23
+ * be sparse.
24
+ */
25
+ export function buildOffsets(heights, count, fallback, gap = 0) {
26
+ const offsets = new Array(count + 1);
27
+ offsets[0] = 0;
28
+ let prev = 0;
29
+ for (let i = 0; i < count; i++) {
30
+ const h = heights[i];
31
+ prev += (Number.isFinite(h) && h > 0 ? h : fallback) + gap;
32
+ offsets[i + 1] = prev;
33
+ }
34
+ return offsets;
35
+ }
36
+ /** Largest row index whose TOP edge is at or above y (0 when y ≤ 0). */
37
+ export function findAnchor(offsets, y) {
38
+ let lo = 0;
39
+ let hi = offsets.length - 1;
40
+ if (y <= 0)
41
+ return 0;
42
+ while (lo < hi) {
43
+ const mid = (lo + hi + 1) >> 1;
44
+ if (offsets[mid] <= y)
45
+ lo = mid;
46
+ else
47
+ hi = mid - 1;
48
+ }
49
+ return lo;
50
+ }
51
+ /**
52
+ * Classify a transcript update against the id list already rendered (both in
53
+ * render order):
54
+ *
55
+ * - `'none'` — identical ids in order: keyed rows stay mounted and everything
56
+ * holds (scroll position, measured heights, offsets). No reset.
57
+ * - `'extend'` — append-only growth: the prefix keeps every measured height
58
+ * and the scroll position holds; offsets rebuild lazily for the new length.
59
+ * - `'reset'` — anything else (replacement, reorder, removal): fresh height
60
+ * cache and top-of-pane scroll.
61
+ */
62
+ export function transcriptResetKind(prev, next) {
63
+ if (next.length >= prev.length) {
64
+ for (let i = 0; i < prev.length; i++) {
65
+ if (prev[i] !== next[i])
66
+ return 'reset';
67
+ }
68
+ return next.length === prev.length ? 'none' : 'extend';
69
+ }
70
+ return 'reset';
71
+ }
72
+ /**
73
+ * The row range to render for a scroll position. `first`/`last` are inclusive
74
+ * row indexes padded by `overscan`; every row overlapping the viewport is in
75
+ * range. Returns null-shaped output when the pane can't be measured yet — the
76
+ * component treats a non-positive viewport as "render the overscan around the
77
+ * top" rather than nothing.
78
+ */
79
+ export function windowFor(offsets, scrollTop, viewportH, overscan) {
80
+ const n = offsets.length - 1;
81
+ if (n <= 0)
82
+ return { first: 0, last: 0 };
83
+ const top = Math.max(0, scrollTop);
84
+ const firstOverlap = findAnchor(offsets, top);
85
+ // Row `firstOverlap` may START above `top` (its top edge ≤ y): the row
86
+ // overlapping the viewport top is that one or the next — cover both.
87
+ const first = Math.max(0, firstOverlap - overscan);
88
+ const bottom = top + Math.max(0, viewportH);
89
+ // First row whose TOP edge is at/below the viewport bottom, then one more
90
+ // so a row straddling the bottom edge is not dropped mid-scroll.
91
+ let lo = 0;
92
+ let hi = n;
93
+ while (lo < hi) {
94
+ const mid = (lo + hi) >> 1;
95
+ if (offsets[mid] < bottom)
96
+ lo = mid + 1;
97
+ else
98
+ hi = mid;
99
+ }
100
+ const last = Math.min(n, lo + overscan);
101
+ return { first, last };
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",