From 2d93493ecb91294c8eef2ae8eb1c3339af773f0d Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 29 Jul 2026 16:34:26 -1000 Subject: [PATCH 1/5] beautiful transcript scroll --- app/globals.css | 7 + components/TranscriptPanel.tsx | 5 +- components/TranscriptScrollIndicator.tsx | 358 +++++++++++++++++++++++ 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 components/TranscriptScrollIndicator.tsx diff --git a/app/globals.css b/app/globals.css index 9c642f6..8ff699e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -56,6 +56,13 @@ body { background: transparent; } +/* Tailwind's `scrollbar-none` only sets scrollbar-width; Safari before 18.2 + needs this too. The transcript relies on it to hide its scrollbar in favour + of its own time rail (see TranscriptScrollIndicator). */ +.scrollbar-none::-webkit-scrollbar { + display: none; +} + /* Timeline: split confirmation flash */ @keyframes tl-split-flash { 0% { diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index eecce43..12c1b30 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -20,6 +20,7 @@ import { TRANSCRIPT_ACCEPT, } from "@/lib/parseTranscript"; import type { SpeakerTurn, Word } from "@/lib/types"; +import TranscriptScrollIndicator from "./TranscriptScrollIndicator"; import { getActiveSceneBoundaries, getCutRanges, @@ -561,7 +562,7 @@ export default function TranscriptPanel() {
{busy && ( @@ -719,6 +720,8 @@ export default function TranscriptPanel() { )}
+ + ); } diff --git a/components/TranscriptScrollIndicator.tsx b/components/TranscriptScrollIndicator.tsx new file mode 100644 index 0000000..6fc938e --- /dev/null +++ b/components/TranscriptScrollIndicator.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type RefObject, +} from "react"; +import { useEditorStore } from "@/lib/store"; + +/** Height of the transcript's floating header. The first readable line sits + * just below it, and that line is the one the rail reports a time for. */ +const HEADER_H = 40; +const MIN_THUMB_H = 28; +/** Kept in sync with the label's `h-3.5` so it can be centred on the thumb. */ +const LABEL_H = 14; +/** Candidate spacings between ticks, in seconds, coarsest last. */ +const TICK_STEPS = [5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600]; +const MIN_TICK_GAP = 13; +/** How close a tick must be to the thumb to react, and how far it grows. */ +const TICK_REACH = 52; +const TICK_W = 4; +const TICK_W_ACTIVE = 13; +/** Idle delay before the ticks and the time label retract again. */ +const IDLE_MS = 1000; + +/** A measured line of transcript: where it sits, and when it is spoken. */ +type Anchor = { top: number; time: number }; +type Tick = { time: number; y: number }; + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +/** m:ss (or h:mm past an hour) — no tenths, which only flicker at this size. */ +function railTime(seconds: number): string { + const total = Math.max(0, Math.round(seconds)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + if (h > 0) return `${h}h${String(m).padStart(2, "0")}`; + return `${m}:${String(total % 60).padStart(2, "0")}`; +} + +/** Media time of the transcript at `top`, interpolated between measured lines. */ +function timeAt(anchors: Anchor[], top: number): number { + if (anchors.length === 0) return 0; + let lo = 0; + let hi = anchors.length - 1; + let idx = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (anchors[mid].top <= top) { + idx = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + const a = anchors[idx]; + const b = anchors[idx + 1]; + if (!b || b.top === a.top) return a.time; + const k = clamp((top - a.top) / (b.top - a.top), 0, 1); + return a.time + (b.time - a.time) * k; +} + +/** The inverse of `timeAt`: where `time` falls in scroll coordinates. */ +function topAt(anchors: Anchor[], time: number): number { + if (anchors.length === 0) return 0; + let lo = 0; + let hi = anchors.length - 1; + let idx = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (anchors[mid].time <= time) { + idx = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + const a = anchors[idx]; + const b = anchors[idx + 1]; + if (!b || b.time === a.time) return a.top; + const k = clamp((time - a.time) / (b.time - a.time), 0, 1); + return a.top + (b.top - a.top) * k; +} + +/** + * Replaces the transcript's native scrollbar with a rail that reads out the + * media time of whatever is currently scrolled into view. Ticks mark round + * timestamps at the position where they appear in the text, so the thumb + * sweeps past them as those moments reach the top of the pane. + * + * Everything that changes per frame (thumb, label, tick emphasis) is written + * straight to the DOM — a scroll this fast should not re-render the panel. + */ +export default function TranscriptScrollIndicator({ + scrollRef, + contentRef, +}: { + scrollRef: RefObject; + contentRef: RefObject; +}) { + const words = useEditorStore((s) => s.words); + const starts = useMemo(() => { + const map = new Map(); + for (const w of words) map.set(w.id, w.start); + return map; + }, [words]); + + const railRef = useRef(null); + const thumbRef = useRef(null); + const labelRef = useRef(null); + const tickEls = useRef<(HTMLSpanElement | null)[]>([]); + const anchors = useRef([]); + const tickPos = useRef([]); + const dragging = useRef(false); + const endDrag = useRef<(() => void) | null>(null); + const hovering = useRef(false); + const idleTimer = useRef(null); + + const [ticks, setTicks] = useState([]); + const [active, setActive] = useState(false); + const [enabled, setEnabled] = useState(false); + + const paint = useCallback(() => { + const scroller = scrollRef.current; + const rail = railRef.current; + const thumb = thumbRef.current; + const label = labelRef.current; + if (!scroller || !rail || !thumb || !label) return; + const railH = rail.clientHeight; + const viewport = scroller.clientHeight; + const range = scroller.scrollHeight - viewport; + if (railH <= 0 || range <= 1) return; + + const thumbH = Math.max(MIN_THUMB_H, (viewport / scroller.scrollHeight) * railH); + const travel = railH - thumbH; + const scrollTop = clamp(scroller.scrollTop, 0, range); + const y = (scrollTop / range) * travel; + + thumb.style.height = `${thumbH}px`; + thumb.style.transform = `translateY(${y}px)`; + label.style.transform = `translateY(${clamp(y - LABEL_H / 2, 0, railH - LABEL_H)}px)`; + label.textContent = railTime(timeAt(anchors.current, scrollTop + HEADER_H)); + + const els = tickEls.current; + const count = Math.min(els.length, tickPos.current.length); + for (let i = 0; i < count; i++) { + const el = els[i]; + if (!el) continue; + const distance = Math.abs(tickPos.current[i].y - y); + const near = distance > TICK_REACH ? 0 : 1 - distance / TICK_REACH; + // Smoothstep, so the emphasis eases in instead of ramping linearly. + const e = near * near * (3 - 2 * near); + el.style.width = `${TICK_W + (TICK_W_ACTIVE - TICK_W) * e}px`; + el.style.opacity = `${0.3 + 0.7 * e}`; + } + }, [scrollRef]); + + const measure = useCallback(() => { + const scroller = scrollRef.current; + const content = contentRef.current; + const rail = railRef.current; + if (!scroller || !content || !rail) return; + + // One anchor per rendered line: word spans share an `offsetTop` when they + // wrap onto the same line, so the first of each line is enough. + const base = content.offsetTop; + const measured: Anchor[] = []; + let lastTop = -1; + for (const span of content.querySelectorAll("[data-wid]")) { + const top = span.offsetTop; + if (top === lastTop) continue; + const time = starts.get(Number(span.dataset.wid)); + if (time === undefined) continue; + lastTop = top; + measured.push({ top: base + top, time }); + } + anchors.current = measured; + + const viewport = scroller.clientHeight; + const range = scroller.scrollHeight - viewport; + const railH = rail.clientHeight; + const first = measured[0]?.time ?? 0; + const last = measured[measured.length - 1]?.time ?? 0; + if (measured.length < 2 || range <= 1 || railH <= 0 || last <= first) { + tickPos.current = []; + setTicks([]); + setEnabled(false); + return; + } + + const thumbH = Math.max(MIN_THUMB_H, (viewport / scroller.scrollHeight) * railH); + const travel = railH - thumbH; + const spanned = last - first; + const step = + TICK_STEPS.find((s) => (s / spanned) * travel >= MIN_TICK_GAP) ?? + TICK_STEPS[TICK_STEPS.length - 1]; + const next: Tick[] = []; + for (let t = Math.ceil(first / step) * step; t <= last; t += step) { + next.push({ time: t, y: clamp((topAt(measured, t) / range) * travel, 0, travel) }); + } + tickPos.current = next; + setTicks(next); + setEnabled(true); + }, [scrollRef, contentRef, starts]); + + /** Wake the rail up, and arm the retraction that follows a quiet moment. */ + const bump = useCallback(() => { + setActive(true); + if (idleTimer.current !== null) window.clearTimeout(idleTimer.current); + idleTimer.current = window.setTimeout(() => { + idleTimer.current = null; + if (!dragging.current && !hovering.current) setActive(false); + }, IDLE_MS); + }, []); + + useEffect(() => { + return () => { + if (idleTimer.current !== null) window.clearTimeout(idleTimer.current); + endDrag.current?.(); + }; + }, []); + + useEffect(() => { + measure(); + }, [measure]); + + // Ticks were just (re)rendered, so their elements can take their emphasis. + useEffect(() => { + tickEls.current.length = ticks.length; + paint(); + }, [ticks, paint]); + + useEffect(() => { + const scroller = scrollRef.current; + if (!scroller) return; + let frame = 0; + const onScroll = () => { + bump(); + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + paint(); + }); + }; + scroller.addEventListener("scroll", onScroll, { passive: true }); + return () => { + scroller.removeEventListener("scroll", onScroll); + if (frame) cancelAnimationFrame(frame); + }; + }, [scrollRef, paint, bump]); + + // Re-measure when the pane resizes or the transcript reflows. + useEffect(() => { + const scroller = scrollRef.current; + const content = contentRef.current; + if (!scroller || !content) return; + let frame = 0; + const observer = new ResizeObserver(() => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + measure(); + }); + }); + observer.observe(scroller); + observer.observe(content); + return () => { + observer.disconnect(); + if (frame) cancelAnimationFrame(frame); + }; + }, [scrollRef, contentRef, measure]); + + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { + const scroller = scrollRef.current; + const rail = railRef.current; + const thumb = thumbRef.current; + if (!scroller || !rail || !thumb) return; + e.preventDefault(); + + const railRect = rail.getBoundingClientRect(); + const thumbRect = thumb.getBoundingClientRect(); + // Grabbing the thumb keeps the offset; clicking the rail centres it. + const grab = + e.clientY >= thumbRect.top && e.clientY <= thumbRect.bottom + ? e.clientY - thumbRect.top + : thumbRect.height / 2; + const travel = railRect.height - thumbRect.height; + const range = scroller.scrollHeight - scroller.clientHeight; + + const drag = (clientY: number) => { + if (travel <= 0) return; + const y = clamp(clientY - railRect.top - grab, 0, travel); + scroller.scrollTop = (y / travel) * range; + }; + const onMove = (ev: PointerEvent) => drag(ev.clientY); + const onUp = () => { + dragging.current = false; + endDrag.current = null; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + bump(); + }; + + dragging.current = true; + endDrag.current = onUp; + bump(); + drag(e.clientY); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, + [scrollRef, bump] + ); + + return ( +
{ + hovering.current = true; + bump(); + }} + onPointerLeave={() => { + hovering.current = false; + bump(); + }} + className={`absolute top-10 right-0 bottom-0 z-20 w-3 touch-none select-none ${enabled ? "" : "pointer-events-none opacity-0" + }`} + > + {ticks.map((tick, i) => ( + { + tickEls.current[i] = el; + }} + style={{ top: tick.y, transitionDelay: `${Math.min(i * 6, 140)}ms` }} + className={`absolute right-2.5 h-px origin-right rounded-full bg-zinc-400 transition-transform duration-300 ease-out ${active ? "scale-x-100" : "scale-x-0" + }`} + /> + ))} +
+ +
+ ); +} From 3e28c3692d3766bee827e1e767b7ab59318f6306 Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 29 Jul 2026 16:42:26 -1000 Subject: [PATCH 2/5] minor ticks --- components/TranscriptScrollIndicator.tsx | 99 ++++++++++++++++++++---- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/components/TranscriptScrollIndicator.tsx b/components/TranscriptScrollIndicator.tsx index 6fc938e..049f195 100644 --- a/components/TranscriptScrollIndicator.tsx +++ b/components/TranscriptScrollIndicator.tsx @@ -20,19 +20,45 @@ const LABEL_H = 14; /** Candidate spacings between ticks, in seconds, coarsest last. */ const TICK_STEPS = [5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600]; const MIN_TICK_GAP = 13; +/** Timestamps land wherever the text puts them, so the gaps between them are + * filled with plain ruler ticks at roughly this pitch. */ +const MINOR_GAP = 7; +const MAX_SUBDIVISIONS = 6; /** How close a tick must be to the thumb to react, and how far it grows. */ const TICK_REACH = 52; -const TICK_W = 4; -const TICK_W_ACTIVE = 13; +const MAJOR: Emphasis = { minW: 4, maxW: 13, minOpacity: 0.3, maxOpacity: 1 }; +const MINOR: Emphasis = { minW: 2, maxW: 6, minOpacity: 0.12, maxOpacity: 0.5 }; /** Idle delay before the ticks and the time label retract again. */ const IDLE_MS = 1000; /** A measured line of transcript: where it sits, and when it is spoken. */ type Anchor = { top: number; time: number }; type Tick = { time: number; y: number }; +/** How a tick reads at rest and when the thumb reaches it. */ +type Emphasis = { minW: number; maxW: number; minOpacity: number; maxOpacity: number }; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +/** Grow and brighten ticks as the thumb passes them. */ +function emphasize( + els: (HTMLSpanElement | null)[], + positions: number[], + thumbY: number, + { minW, maxW, minOpacity, maxOpacity }: Emphasis +) { + const count = Math.min(els.length, positions.length); + for (let i = 0; i < count; i++) { + const el = els[i]; + if (!el) continue; + const distance = Math.abs(positions[i] - thumbY); + const near = distance > TICK_REACH ? 0 : 1 - distance / TICK_REACH; + // Smoothstep, so the emphasis eases in instead of ramping linearly. + const e = near * near * (3 - 2 * near); + el.style.width = `${minW + (maxW - minW) * e}px`; + el.style.opacity = `${minOpacity + (maxOpacity - minOpacity) * e}`; + } +} + /** m:ss (or h:mm past an hour) — no tenths, which only flicker at this size. */ function railTime(seconds: number): string { const total = Math.max(0, Math.round(seconds)); @@ -64,6 +90,32 @@ function timeAt(anchors: Anchor[], top: number): number { return a.time + (b.time - a.time) * k; } +/** + * Plain ruler ticks filling the gaps between timestamps at a roughly even + * pitch. Timestamps land wherever the text puts them, which leaves the rail + * looking sparse and arbitrary on its own. + */ +function subdivide(majors: number[], travel: number): number[] { + if (majors.length < 2) return []; + const stepsIn = (gap: number) => clamp(Math.round(gap / MINOR_GAP), 1, MAX_SUBDIVISIONS); + const out: number[] = []; + for (let i = 0; i < majors.length - 1; i++) { + const gap = majors[i + 1] - majors[i]; + if (gap <= 0) continue; + const steps = stepsIn(gap); + for (let k = 1; k < steps; k++) out.push(majors[i] + (gap * k) / steps); + } + // Carry the neighbouring pitch past the outermost timestamps so the ruler + // runs the whole rail instead of stopping short at both ends. + const head = majors[0]; + const headPitch = (majors[1] - head) / stepsIn(majors[1] - head); + if (headPitch >= 1) for (let y = head - headPitch; y >= 0; y -= headPitch) out.push(y); + const tail = majors[majors.length - 1]; + const tailPitch = (tail - majors[majors.length - 2]) / stepsIn(tail - majors[majors.length - 2]); + if (tailPitch >= 1) for (let y = tail + tailPitch; y <= travel; y += tailPitch) out.push(y); + return out.sort((a, b) => a - b); +} + /** The inverse of `timeAt`: where `time` falls in scroll coordinates. */ function topAt(anchors: Anchor[], time: number): number { if (anchors.length === 0) return 0; @@ -113,14 +165,17 @@ export default function TranscriptScrollIndicator({ const thumbRef = useRef(null); const labelRef = useRef(null); const tickEls = useRef<(HTMLSpanElement | null)[]>([]); + const minorEls = useRef<(HTMLSpanElement | null)[]>([]); const anchors = useRef([]); - const tickPos = useRef([]); + const tickPos = useRef([]); + const minorPos = useRef([]); const dragging = useRef(false); const endDrag = useRef<(() => void) | null>(null); const hovering = useRef(false); const idleTimer = useRef(null); const [ticks, setTicks] = useState([]); + const [minors, setMinors] = useState([]); const [active, setActive] = useState(false); const [enabled, setEnabled] = useState(false); @@ -145,18 +200,8 @@ export default function TranscriptScrollIndicator({ label.style.transform = `translateY(${clamp(y - LABEL_H / 2, 0, railH - LABEL_H)}px)`; label.textContent = railTime(timeAt(anchors.current, scrollTop + HEADER_H)); - const els = tickEls.current; - const count = Math.min(els.length, tickPos.current.length); - for (let i = 0; i < count; i++) { - const el = els[i]; - if (!el) continue; - const distance = Math.abs(tickPos.current[i].y - y); - const near = distance > TICK_REACH ? 0 : 1 - distance / TICK_REACH; - // Smoothstep, so the emphasis eases in instead of ramping linearly. - const e = near * near * (3 - 2 * near); - el.style.width = `${TICK_W + (TICK_W_ACTIVE - TICK_W) * e}px`; - el.style.opacity = `${0.3 + 0.7 * e}`; - } + emphasize(tickEls.current, tickPos.current, y, MAJOR); + emphasize(minorEls.current, minorPos.current, y, MINOR); }, [scrollRef]); const measure = useCallback(() => { @@ -187,7 +232,9 @@ export default function TranscriptScrollIndicator({ const last = measured[measured.length - 1]?.time ?? 0; if (measured.length < 2 || range <= 1 || railH <= 0 || last <= first) { tickPos.current = []; + minorPos.current = []; setTicks([]); + setMinors([]); setEnabled(false); return; } @@ -202,8 +249,14 @@ export default function TranscriptScrollIndicator({ for (let t = Math.ceil(first / step) * step; t <= last; t += step) { next.push({ time: t, y: clamp((topAt(measured, t) / range) * travel, 0, travel) }); } - tickPos.current = next; + const between = subdivide( + next.map((tick) => tick.y), + travel + ); + tickPos.current = next.map((tick) => tick.y); + minorPos.current = between; setTicks(next); + setMinors(between); setEnabled(true); }, [scrollRef, contentRef, starts]); @@ -231,8 +284,9 @@ export default function TranscriptScrollIndicator({ // Ticks were just (re)rendered, so their elements can take their emphasis. useEffect(() => { tickEls.current.length = ticks.length; + minorEls.current.length = minors.length; paint(); - }, [ticks, paint]); + }, [ticks, minors, paint]); useEffect(() => { const scroller = scrollRef.current; @@ -332,6 +386,17 @@ export default function TranscriptScrollIndicator({ className={`absolute top-10 right-0 bottom-0 z-20 w-3 touch-none select-none ${enabled ? "" : "pointer-events-none opacity-0" }`} > + {minors.map((y, i) => ( + { + minorEls.current[i] = el; + }} + style={{ top: y, transitionDelay: `${Math.min(i * 4, 160)}ms` }} + className={`absolute right-2.5 h-px origin-right rounded-full bg-zinc-400 transition-transform duration-300 ease-out ${active ? "scale-x-100" : "scale-x-0" + }`} + /> + ))} {ticks.map((tick, i) => ( Date: Wed, 29 Jul 2026 16:48:07 -1000 Subject: [PATCH 3/5] anchor text to center --- components/TranscriptScrollIndicator.tsx | 71 +++++++++++++++++------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/components/TranscriptScrollIndicator.tsx b/components/TranscriptScrollIndicator.tsx index 049f195..a0eec7b 100644 --- a/components/TranscriptScrollIndicator.tsx +++ b/components/TranscriptScrollIndicator.tsx @@ -11,8 +11,8 @@ import { } from "react"; import { useEditorStore } from "@/lib/store"; -/** Height of the transcript's floating header. The first readable line sits - * just below it, and that line is the one the rail reports a time for. */ +/** Height of the transcript's floating header. It covers the top of the + * scroller, so the readable area — and its midline — starts below it. */ const HEADER_H = 40; const MIN_THUMB_H = 28; /** Kept in sync with the label's `h-3.5` so it can be centred on the thumb. */ @@ -68,6 +68,37 @@ function railTime(seconds: number): string { return `${m}:${String(total % 60).padStart(2, "0")}`; } +/** The scroller and rail measurements every position derives from. */ +type Geometry = { + railH: number; + viewport: number; + /** Scrollable distance. */ + range: number; + thumbH: number; + /** Distance the thumb's top edge covers, top to bottom. */ + travel: number; +}; + +/** Null when there is nothing to scroll, and so nothing to indicate. */ +function geometryOf(scroller: HTMLElement, railH: number): Geometry | null { + const viewport = scroller.clientHeight; + const range = scroller.scrollHeight - viewport; + if (railH <= 0 || range <= 1) return null; + const thumbH = Math.max(MIN_THUMB_H, (viewport / scroller.scrollHeight) * railH); + return { railH, viewport, range, thumbH, travel: railH - thumbH }; +} + +/** Scroll offset of the pane's midline. The readable area starts below the + * header, so its middle sits a little past half the viewport. */ +const midline = (viewport: number) => (HEADER_H + viewport) / 2; + +/** Rail position for the content at `top`: where the thumb's centre lands once + * that content has been scrolled to the middle of the pane. */ +function railY(top: number, geometry: Geometry): number { + const { range, travel, thumbH, viewport } = geometry; + return ((top - midline(viewport)) / range) * travel + thumbH / 2; +} + /** Media time of the transcript at `top`, interpolated between measured lines. */ function timeAt(anchors: Anchor[], top: number): number { if (anchors.length === 0) return 0; @@ -185,23 +216,23 @@ export default function TranscriptScrollIndicator({ const thumb = thumbRef.current; const label = labelRef.current; if (!scroller || !rail || !thumb || !label) return; - const railH = rail.clientHeight; - const viewport = scroller.clientHeight; - const range = scroller.scrollHeight - viewport; - if (railH <= 0 || range <= 1) return; + const geometry = geometryOf(scroller, rail.clientHeight); + if (!geometry) return; + const { railH, viewport, range, thumbH, travel } = geometry; - const thumbH = Math.max(MIN_THUMB_H, (viewport / scroller.scrollHeight) * railH); - const travel = railH - thumbH; const scrollTop = clamp(scroller.scrollTop, 0, range); const y = (scrollTop / range) * travel; + // Ticks and the label read off the thumb's centre, which is the point that + // tracks the middle of the pane — the text the eye is actually resting on. + const centre = y + thumbH / 2; thumb.style.height = `${thumbH}px`; thumb.style.transform = `translateY(${y}px)`; - label.style.transform = `translateY(${clamp(y - LABEL_H / 2, 0, railH - LABEL_H)}px)`; - label.textContent = railTime(timeAt(anchors.current, scrollTop + HEADER_H)); + label.style.transform = `translateY(${clamp(centre - LABEL_H / 2, 0, railH - LABEL_H)}px)`; + label.textContent = railTime(timeAt(anchors.current, scrollTop + midline(viewport))); - emphasize(tickEls.current, tickPos.current, y, MAJOR); - emphasize(minorEls.current, minorPos.current, y, MINOR); + emphasize(tickEls.current, tickPos.current, centre, MAJOR); + emphasize(minorEls.current, minorPos.current, centre, MINOR); }, [scrollRef]); const measure = useCallback(() => { @@ -225,12 +256,10 @@ export default function TranscriptScrollIndicator({ } anchors.current = measured; - const viewport = scroller.clientHeight; - const range = scroller.scrollHeight - viewport; - const railH = rail.clientHeight; + const geometry = geometryOf(scroller, rail.clientHeight); const first = measured[0]?.time ?? 0; const last = measured[measured.length - 1]?.time ?? 0; - if (measured.length < 2 || range <= 1 || railH <= 0 || last <= first) { + if (!geometry || measured.length < 2 || last <= first) { tickPos.current = []; minorPos.current = []; setTicks([]); @@ -239,19 +268,21 @@ export default function TranscriptScrollIndicator({ return; } - const thumbH = Math.max(MIN_THUMB_H, (viewport / scroller.scrollHeight) * railH); - const travel = railH - thumbH; + const { railH, travel } = geometry; const spanned = last - first; const step = TICK_STEPS.find((s) => (s / spanned) * travel >= MIN_TICK_GAP) ?? TICK_STEPS[TICK_STEPS.length - 1]; const next: Tick[] = []; for (let t = Math.ceil(first / step) * step; t <= last; t += step) { - next.push({ time: t, y: clamp((topAt(measured, t) / range) * travel, 0, travel) }); + const y = railY(topAt(measured, t), geometry); + // Times that only ever sit above or below the middle of the pane — the + // opening and closing screenful — have nowhere on the rail to land. + if (y >= 0 && y <= railH) next.push({ time: t, y }); } const between = subdivide( next.map((tick) => tick.y), - travel + railH ); tickPos.current = next.map((tick) => tick.y); minorPos.current = between; From c2cfb9ca0c7bf9efc97d28a94ac64ab07929dc8f Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 29 Jul 2026 17:11:41 -1000 Subject: [PATCH 4/5] fix scrolling --- components/TranscriptScrollIndicator.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/TranscriptScrollIndicator.tsx b/components/TranscriptScrollIndicator.tsx index a0eec7b..0198f0a 100644 --- a/components/TranscriptScrollIndicator.tsx +++ b/components/TranscriptScrollIndicator.tsx @@ -24,6 +24,9 @@ const MIN_TICK_GAP = 13; * filled with plain ruler ticks at roughly this pitch. */ const MINOR_GAP = 7; const MAX_SUBDIVISIONS = 6; +/** Matches the ticks' `h-px`. A tick dropped flush with the bottom of the rail + * would hang a pixel past the pane and make the whole panel scrollable. */ +const TICK_H = 1; /** How close a tick must be to the thumb to react, and how far it grows. */ const TICK_REACH = 52; const MAJOR: Emphasis = { minW: 4, maxW: 13, minOpacity: 0.3, maxOpacity: 1 }; @@ -126,7 +129,7 @@ function timeAt(anchors: Anchor[], top: number): number { * pitch. Timestamps land wherever the text puts them, which leaves the rail * looking sparse and arbitrary on its own. */ -function subdivide(majors: number[], travel: number): number[] { +function subdivide(majors: number[], limit: number): number[] { if (majors.length < 2) return []; const stepsIn = (gap: number) => clamp(Math.round(gap / MINOR_GAP), 1, MAX_SUBDIVISIONS); const out: number[] = []; @@ -143,7 +146,7 @@ function subdivide(majors: number[], travel: number): number[] { if (headPitch >= 1) for (let y = head - headPitch; y >= 0; y -= headPitch) out.push(y); const tail = majors[majors.length - 1]; const tailPitch = (tail - majors[majors.length - 2]) / stepsIn(tail - majors[majors.length - 2]); - if (tailPitch >= 1) for (let y = tail + tailPitch; y <= travel; y += tailPitch) out.push(y); + if (tailPitch >= 1) for (let y = tail + tailPitch; y <= limit; y += tailPitch) out.push(y); return out.sort((a, b) => a - b); } @@ -278,11 +281,11 @@ export default function TranscriptScrollIndicator({ const y = railY(topAt(measured, t), geometry); // Times that only ever sit above or below the middle of the pane — the // opening and closing screenful — have nowhere on the rail to land. - if (y >= 0 && y <= railH) next.push({ time: t, y }); + if (y >= 0 && y <= railH - TICK_H) next.push({ time: t, y }); } const between = subdivide( next.map((tick) => tick.y), - railH + railH - TICK_H ); tickPos.current = next.map((tick) => tick.y); minorPos.current = between; From c4f63cfd4f152191963940ce36aa6a88151b6d70 Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 29 Jul 2026 17:46:34 -1000 Subject: [PATCH 5/5] transparent gradient --- components/TranscriptPanel.tsx | 5 +++-- components/TranscriptScrollIndicator.tsx | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index 12c1b30..e3ccbc2 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -504,7 +504,7 @@ export default function TranscriptPanel() { return ( // min-h-0 keeps this pane from growing to the transcript's full height — // without it the panel wrapper scrolls instead of the list below. -
+
{/* Floats above the scroller rather than sticking inside it, so the rubber-band overscroll only carries the transcript, not the bar. */}
@@ -720,7 +720,8 @@ export default function TranscriptPanel() { )}
- + {/* Gradient overlay */} +
); diff --git a/components/TranscriptScrollIndicator.tsx b/components/TranscriptScrollIndicator.tsx index 0198f0a..5c81ebb 100644 --- a/components/TranscriptScrollIndicator.tsx +++ b/components/TranscriptScrollIndicator.tsx @@ -198,6 +198,7 @@ export default function TranscriptScrollIndicator({ const railRef = useRef(null); const thumbRef = useRef(null); const labelRef = useRef(null); + const gradientRef = useRef(null); const tickEls = useRef<(HTMLSpanElement | null)[]>([]); const minorEls = useRef<(HTMLSpanElement | null)[]>([]); const anchors = useRef([]); @@ -218,7 +219,8 @@ export default function TranscriptScrollIndicator({ const rail = railRef.current; const thumb = thumbRef.current; const label = labelRef.current; - if (!scroller || !rail || !thumb || !label) return; + const gradient = gradientRef.current; + if (!scroller || !rail || !thumb || !label || !gradient) return; const geometry = geometryOf(scroller, rail.clientHeight); if (!geometry) return; const { railH, viewport, range, thumbH, travel } = geometry; @@ -442,6 +444,8 @@ export default function TranscriptScrollIndicator({ }`} /> ))} + {/* Gradient overlay */} +