@joewinke/jatui 0.18.1 → 0.20.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.
@@ -57,6 +57,20 @@
57
57
  * control. For mapped lists, give each row its own `id`/`expanded`/`collapsible`
58
58
  * state (e.g. a `SvelteSet` of ids, with the id interpolating the row's own id) —
59
59
  * one action instance attaches per row element.
60
+ *
61
+ * ─── Expand on focus (expandOnFocus / onExpand) ───────────────────────────────
62
+ *
63
+ * By default a `focusin` inside a COLLAPSED region asks the consumer to expand
64
+ * it (upstream of the jat-ngdm1 fix, where tabbing into a clipped inline-edit
65
+ * textarea left it unreadable and clicking Show more blurred it). The action
66
+ * never mutates the consumer's state — it fires `onExpand` and the consumer
67
+ * sets `expanded = true`, keeping the toggle label and `aria-expanded` in sync:
68
+ *
69
+ * <div use:collapse={{ expanded, onCollapsible, onExpand: () => (expanded = true) }}>
70
+ *
71
+ * Pass `expandOnFocus: false` to opt out (e.g. read-only log dumps where
72
+ * focus-driven expansion would be surprising). The `<Collapse>` component
73
+ * wires this out of the box.
60
74
  */
61
75
  export interface CollapseOptions {
62
76
  /** Max collapsed height in px. Content taller than this collapses. Default 120. */
@@ -73,6 +87,19 @@ export interface CollapseOptions {
73
87
  * content is shorter than the threshold.
74
88
  */
75
89
  onCollapsible?: (collapsible: boolean) => void;
90
+ /**
91
+ * Auto-expand when focus moves into the collapsed region (focusin).
92
+ * Default true. Expansion goes through `onExpand` — the consumer still owns
93
+ * the `expanded` state (same contract as `onCollapsible`), so wire it:
94
+ * `use:collapse={{ expanded, onExpand: () => (expanded = true) }}`.
95
+ * With no `onExpand` there is nothing to call and focusin is a no-op.
96
+ */
97
+ expandOnFocus?: boolean;
98
+ /**
99
+ * Called when the action decides the region should expand (focusin
100
+ * auto-expand while collapsed). Set your `expanded` state to true here.
101
+ */
102
+ onExpand?: () => void;
76
103
  }
77
104
  export declare function collapse(node: HTMLElement, options?: CollapseOptions): {
78
105
  update(next?: CollapseOptions): void;
@@ -57,8 +57,22 @@
57
57
  * control. For mapped lists, give each row its own `id`/`expanded`/`collapsible`
58
58
  * state (e.g. a `SvelteSet` of ids, with the id interpolating the row's own id) —
59
59
  * one action instance attaches per row element.
60
+ *
61
+ * ─── Expand on focus (expandOnFocus / onExpand) ───────────────────────────────
62
+ *
63
+ * By default a `focusin` inside a COLLAPSED region asks the consumer to expand
64
+ * it (upstream of the jat-ngdm1 fix, where tabbing into a clipped inline-edit
65
+ * textarea left it unreadable and clicking Show more blurred it). The action
66
+ * never mutates the consumer's state — it fires `onExpand` and the consumer
67
+ * sets `expanded = true`, keeping the toggle label and `aria-expanded` in sync:
68
+ *
69
+ * <div use:collapse={{ expanded, onCollapsible, onExpand: () => (expanded = true) }}>
70
+ *
71
+ * Pass `expandOnFocus: false` to opt out (e.g. read-only log dumps where
72
+ * focus-driven expansion would be surprising). The `<Collapse>` component
73
+ * wires this out of the box.
60
74
  */
61
- const DEFAULTS = { threshold: 120, expanded: false, fadeStart: 55, epsilon: 4 };
75
+ const DEFAULTS = { threshold: 120, expanded: false, fadeStart: 55, epsilon: 4, expandOnFocus: true };
62
76
  export function collapse(node, options = {}) {
63
77
  let opts = { ...DEFAULTS, ...options };
64
78
  let collapsible = false;
@@ -98,6 +112,19 @@ export function collapse(node, options = {}) {
98
112
  cancelAnimationFrame(rafId);
99
113
  rafId = requestAnimationFrame(measure);
100
114
  }
115
+ // Focus moving into a collapsed region means the user is interacting with
116
+ // content they can't fully see (the jat-ngdm1 case: tabbing into a clipped
117
+ // inline-edit textarea, then clicking Show more blurred it — three clicks
118
+ // for one edit). Expand instead of leaving the focused child clipped.
119
+ function onFocusin() {
120
+ if (!opts.expandOnFocus)
121
+ return;
122
+ // Only when actually collapsed — already-expanded or too-short content is a no-op.
123
+ if (!collapsible || opts.expanded)
124
+ return;
125
+ opts.onExpand?.();
126
+ }
127
+ node.addEventListener('focusin', onFocusin);
101
128
  // Re-measure when the content's natural size changes (streaming, {@html}, lazy images).
102
129
  const ro = new ResizeObserver(scheduleMeasure);
103
130
  ro.observe(node);
@@ -115,6 +142,7 @@ export function collapse(node, options = {}) {
115
142
  destroy() {
116
143
  cancelAnimationFrame(rafId);
117
144
  ro.disconnect();
145
+ node.removeEventListener('focusin', onFocusin);
118
146
  },
119
147
  };
120
148
  }
@@ -1,4 +1,11 @@
1
1
  <script lang="ts">
2
+ import {
3
+ BURNDOWN_DONE,
4
+ burndownScope,
5
+ buildSeriesPoints,
6
+ type BurndownTask,
7
+ } from "./burndownSeries"
8
+
2
9
  interface OtherMilestone {
3
10
  ordinal: string // "01", "02", etc.
4
11
  name: string
@@ -23,7 +30,7 @@
23
30
  paymentDate?: string | null
24
31
  } = $props()
25
32
 
26
- const DONE = new Set(["accepted", "deployed", "closed", "completed"])
33
+ const DONE = BURNDOWN_DONE
27
34
 
28
35
  // Layout constants
29
36
  const MARGIN = { top: 16, right: 20, bottom: 32, left: 40 }
@@ -62,47 +69,14 @@
62
69
  selected = milestoneFilter
63
70
  })
64
71
 
65
- function buildPts(milestoneId: string, startDay: number, totalDays: number) {
66
- const mt = tasks.filter(
67
- (t) => t.milestone_id === milestoneId && t.status !== "dev",
68
- )
69
- if (!mt.length) return null
70
- const creations = mt
71
- .map((t) =>
72
- new Date(new Date(t.created_at).setHours(0, 0, 0, 0)).getTime(),
73
- )
74
- .sort((a, b) => a - b)
75
- const completions = mt
76
- .filter((t) => DONE.has(t.status))
77
- .map((t) => {
78
- const d = t.closed_at
79
- ? new Date(t.closed_at)
80
- : t.completed_at
81
- ? new Date(t.completed_at)
82
- : new Date(t.updated_at)
83
- return new Date(d.setHours(0, 0, 0, 0)).getTime()
84
- })
85
- .sort((a, b) => a - b)
86
- const pts: { x: number; y: number }[] = []
87
- for (let d = 0; d <= totalDays; d++) {
88
- const ts = startDay + d * 86400000
89
- pts.push({
90
- x: d,
91
- y: Math.max(
92
- 0,
93
- creations.filter((c) => c <= ts).length -
94
- completions.filter((c) => c <= ts).length,
95
- ),
96
- })
97
- }
98
- return pts
72
+ function buildPts(taskList: BurndownTask[], startDay: number, totalDays: number) {
73
+ return buildSeriesPoints(taskList, startDay, totalDays, DONE)
99
74
  }
100
75
 
76
+ let wholeList = $derived(selected === "" && activeMilestones.length === 0)
77
+
101
78
  let range = $derived.by((): { startDay: number; totalDays: number } => {
102
- const relevant =
103
- selected === ""
104
- ? tasks.filter((t) => t.status !== "dev" && t.milestone_id)
105
- : tasks.filter((t) => t.milestone_id === selected && t.status !== "dev")
79
+ const relevant = burndownScope(tasks, selected, activeMilestones.map((m) => m.id)).tasks
106
80
  if (!relevant.length) return { startDay: Date.now(), totalDays: 1 }
107
81
  const min = relevant.reduce(
108
82
  (m, t) => Math.min(m, new Date(t.created_at).getTime()),
@@ -116,9 +90,30 @@
116
90
  let lines = $derived.by(() => {
117
91
  const { startDay, totalDays } = range
118
92
  if (selected === "") {
93
+ // jatui-rt1no: no task carries a milestone_id → one series over the
94
+ // whole list, instead of the silent "No data" empty state.
95
+ if (wholeList) {
96
+ const pts = buildPts(tasks, startDay, totalDays)
97
+ return pts
98
+ ? [
99
+ {
100
+ id: "__all__",
101
+ name: "All tasks",
102
+ color: PALETTE[0],
103
+ pts,
104
+ },
105
+ ]
106
+ : []
107
+ }
119
108
  return activeMilestones
120
109
  .map((m, i) => {
121
- const pts = buildPts(m.id, startDay, totalDays)
110
+ const pts = buildPts(
111
+ tasks.filter(
112
+ (t) => t.milestone_id === m.id && t.status !== "dev",
113
+ ),
114
+ startDay,
115
+ totalDays,
116
+ )
122
117
  return pts
123
118
  ? {
124
119
  id: m.id,
@@ -136,7 +131,13 @@
136
131
  }[]
137
132
  } else {
138
133
  const m = (milestones as any[]).find((m) => m.id === selected)
139
- const pts = buildPts(selected, startDay, totalDays)
134
+ const pts = buildPts(
135
+ tasks.filter(
136
+ (t) => t.milestone_id === selected && t.status !== "dev",
137
+ ),
138
+ startDay,
139
+ totalDays,
140
+ )
140
141
  return pts
141
142
  ? [{ id: selected, name: m?.name ?? "", color: PALETTE[0], pts }]
142
143
  : []
@@ -210,10 +211,7 @@
210
211
  const added = new Array(totalDays + 1).fill(0)
211
212
  const closed = new Array(totalDays + 1).fill(0)
212
213
 
213
- const relevantTasks =
214
- selected === ""
215
- ? tasks.filter((t) => t.status !== "dev" && t.milestone_id)
216
- : tasks.filter((t) => t.milestone_id === selected && t.status !== "dev")
214
+ const relevantTasks = burndownScope(tasks, selected, activeMilestones.map((m) => m.id)).tasks
217
215
 
218
216
  for (const t of relevantTasks) {
219
217
  const cd = Math.round(
@@ -11,6 +11,10 @@
11
11
  * <Collapse>
12
12
  * {@html renderedMarkdown}
13
13
  * </Collapse>
14
+ *
15
+ * Focus-driven expansion: by default (`expandOnFocus`, default true) a
16
+ * focusin inside a collapsed region expands it — tabbing into a clipped
17
+ * editable child never leaves it unreadable (upstream of jat-ngdm1).
14
18
  */
15
19
 
16
20
  interface Props {
@@ -24,6 +28,12 @@
24
28
  moreLabel?: string;
25
29
  /** Toggle label when expanded. Default 'Show less ↑'. */
26
30
  lessLabel?: string;
31
+ /**
32
+ * Auto-expand when focus moves into the collapsed region (focusin).
33
+ * Default true — tabbing into a clipped editable child expands the region
34
+ * instead of leaving it unreadable (upstream of jat-ngdm1).
35
+ */
36
+ expandOnFocus?: boolean;
27
37
  /** Extra classes on the content region. */
28
38
  class?: string;
29
39
  children?: import('svelte').Snippet;
@@ -35,6 +45,7 @@
35
45
  expanded = $bindable(false),
36
46
  moreLabel = 'Show more ↓',
37
47
  lessLabel = 'Show less ↑',
48
+ expandOnFocus = true,
38
49
  class: className = '',
39
50
  children,
40
51
  }: Props = $props();
@@ -61,12 +72,16 @@
61
72
  );
62
73
  </script>
63
74
 
75
+ <!-- svelte-ignore a11y_no_static_element_interactions -- focusin only observes focus moving into an editable child; the wrapper takes no focus itself -->
64
76
  <div
65
77
  id={bodyId}
66
78
  class={`jatui-collapse-outer ${className}`.trim()}
67
79
  class:jatui-collapse-outer-closed={collapsible && !expanded}
68
80
  style="--collapse-threshold: {threshold}px; --collapse-gradient: {gradient};"
69
81
  aria-expanded={expanded || !collapsible}
82
+ onfocusin={() => {
83
+ if (expandOnFocus && collapsible && !expanded) expanded = true;
84
+ }}
70
85
  >
71
86
  <div bind:this={innerEl} class="jatui-collapse-inner">
72
87
  {#if children}
@@ -10,6 +10,10 @@
10
10
  * <Collapse>
11
11
  * {@html renderedMarkdown}
12
12
  * </Collapse>
13
+ *
14
+ * Focus-driven expansion: by default (`expandOnFocus`, default true) a
15
+ * focusin inside a collapsed region expands it — tabbing into a clipped
16
+ * editable child never leaves it unreadable (upstream of jat-ngdm1).
13
17
  */
14
18
  interface Props {
15
19
  /** Max collapsed height in px. Default 120. */
@@ -22,6 +26,12 @@ interface Props {
22
26
  moreLabel?: string;
23
27
  /** Toggle label when expanded. Default 'Show less ↑'. */
24
28
  lessLabel?: string;
29
+ /**
30
+ * Auto-expand when focus moves into the collapsed region (focusin).
31
+ * Default true — tabbing into a clipped editable child expands the region
32
+ * instead of leaving it unreadable (upstream of jat-ngdm1).
33
+ */
34
+ expandOnFocus?: boolean;
25
35
  /** Extra classes on the content region. */
26
36
  class?: string;
27
37
  children?: import('svelte').Snippet;
@@ -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;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure, DOM-free core of BurndownChart (jatui-rt1no).
3
+ *
4
+ * Extracted from the component so the series-building decisions are unit
5
+ * testable (`npm run test:unit`) — same pattern as sectionNavSpy.ts.
6
+ *
7
+ * Key behaviour: when no task in the list carries a milestone_id, the chart
8
+ * treats the WHOLE list as one series instead of rendering "No data".
9
+ * Consumers previously had to synthesize a milestone_id (the IDE's epic Burn
10
+ * tab passes the epicId as one) to see anything at all.
11
+ */
12
+ export declare const BURNDOWN_DONE: Set<string>;
13
+ export interface BurndownTask {
14
+ status: string;
15
+ milestone_id?: string | null;
16
+ created_at: string;
17
+ closed_at?: string | null;
18
+ completed_at?: string | null;
19
+ updated_at: string;
20
+ [key: string]: unknown;
21
+ }
22
+ /**
23
+ * Which tasks does the chart render for the current selection?
24
+ *
25
+ * - A specific milestone selected → only that milestone's non-dev tasks.
26
+ * - "All" selected with at least one milestone-tagged non-dev task → the
27
+ * milestone-tagged tasks (per-milestone series, unchanged legacy behaviour).
28
+ * - "All" selected and NO task has a milestone_id → the whole non-dev list
29
+ * as one series (the jatui-rt1no fallback).
30
+ */
31
+ export declare function burndownScope(tasks: BurndownTask[], selected: string, activeMilestoneIds: Iterable<string>): {
32
+ tasks: BurndownTask[];
33
+ wholeList: boolean;
34
+ };
35
+ /** Completion timestamp with the closed_at → completed_at → updated_at fallback. */
36
+ export declare function completionTs(t: BurndownTask): Date;
37
+ /**
38
+ * Open-task count per day over the chart's range — the burn-down line.
39
+ * Returns null when the task list is empty (the chart then shows "No data",
40
+ * which is correct for a genuinely empty list).
41
+ */
42
+ export declare function buildSeriesPoints(tasks: BurndownTask[], startDay: number, totalDays: number, done?: Set<string>): {
43
+ x: number;
44
+ y: number;
45
+ }[] | null;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure, DOM-free core of BurndownChart (jatui-rt1no).
3
+ *
4
+ * Extracted from the component so the series-building decisions are unit
5
+ * testable (`npm run test:unit`) — same pattern as sectionNavSpy.ts.
6
+ *
7
+ * Key behaviour: when no task in the list carries a milestone_id, the chart
8
+ * treats the WHOLE list as one series instead of rendering "No data".
9
+ * Consumers previously had to synthesize a milestone_id (the IDE's epic Burn
10
+ * tab passes the epicId as one) to see anything at all.
11
+ */
12
+ export const BURNDOWN_DONE = new Set([
13
+ "accepted",
14
+ "deployed",
15
+ "closed",
16
+ "completed",
17
+ ]);
18
+ /**
19
+ * Which tasks does the chart render for the current selection?
20
+ *
21
+ * - A specific milestone selected → only that milestone's non-dev tasks.
22
+ * - "All" selected with at least one milestone-tagged non-dev task → the
23
+ * milestone-tagged tasks (per-milestone series, unchanged legacy behaviour).
24
+ * - "All" selected and NO task has a milestone_id → the whole non-dev list
25
+ * as one series (the jatui-rt1no fallback).
26
+ */
27
+ export function burndownScope(tasks, selected, activeMilestoneIds) {
28
+ const nonDev = tasks.filter((t) => t.status !== "dev");
29
+ if (selected !== "") {
30
+ return {
31
+ tasks: nonDev.filter((t) => t.milestone_id === selected),
32
+ wholeList: false,
33
+ };
34
+ }
35
+ const wholeList = [...activeMilestoneIds].length === 0;
36
+ return {
37
+ tasks: wholeList ? nonDev : nonDev.filter((t) => t.milestone_id),
38
+ wholeList,
39
+ };
40
+ }
41
+ /** Completion timestamp with the closed_at → completed_at → updated_at fallback. */
42
+ export function completionTs(t) {
43
+ const d = t.closed_at
44
+ ? new Date(t.closed_at)
45
+ : t.completed_at
46
+ ? new Date(t.completed_at)
47
+ : new Date(t.updated_at);
48
+ return new Date(d.setHours(0, 0, 0, 0));
49
+ }
50
+ /**
51
+ * Open-task count per day over the chart's range — the burn-down line.
52
+ * Returns null when the task list is empty (the chart then shows "No data",
53
+ * which is correct for a genuinely empty list).
54
+ */
55
+ export function buildSeriesPoints(tasks, startDay, totalDays, done = BURNDOWN_DONE) {
56
+ if (!tasks.length)
57
+ return null;
58
+ const creations = tasks
59
+ .map((t) => new Date(new Date(t.created_at).setHours(0, 0, 0, 0)).getTime())
60
+ .sort((a, b) => a - b);
61
+ const completions = tasks
62
+ .filter((t) => done.has(t.status))
63
+ .map(completionTs)
64
+ .map((d) => d.getTime())
65
+ .sort((a, b) => a - b);
66
+ const pts = [];
67
+ for (let d = 0; d <= totalDays; d++) {
68
+ const ts = startDay + d * 86400000;
69
+ pts.push({
70
+ x: d,
71
+ y: Math.max(0, creations.filter((c) => c <= ts).length -
72
+ completions.filter((c) => c <= ts).length),
73
+ });
74
+ }
75
+ return pts;
76
+ }
@@ -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.20.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",