@@ -68,6 +68,13 @@ export interface UseSegmentWindowArgs {
6868 book : Book ;
6969 /** Current scripture reference; the active verse it names is the recenter anchor. */
7070 scrRef : SerializedVerseRef ;
71+ /**
72+ * Token ref of the currently focused word token, or `undefined` when nothing is focused. Gated
73+ * alongside {@link UseSegmentWindowResult.displayScrRef} so the per-token focus highlight and the
74+ * link-button active state only move behind the recenter fade on external nav — never on the old,
75+ * still-visible content before the fade-out starts.
76+ */
77+ focusedTokenRef : string | undefined ;
7178 /** Ref to the scrollable list container; used to read/adjust scroll position and host sentinels. */
7279 scrollContainerRef : RefObject < HTMLElement | undefined > ;
7380 /**
@@ -96,6 +103,13 @@ export interface UseSegmentWindowResult {
96103 * lockstep.
97104 */
98105 displayScrRef : SerializedVerseRef ;
106+ /**
107+ * Token ref the list should highlight as focused. Gated on the same clock as {@link displayScrRef}
108+ * so the per-token focus and link-button active state move only at the recenter midpoint, behind
109+ * the fade — never on the old content before the fade-out begins. Tracks `focusedTokenRef`
110+ * immediately for internal nav and the initial mount.
111+ */
112+ displayFocusedTokenRef : string | undefined ;
99113 /** Ref callback for the invisible sentinel placed above the first segment. */
100114 topSentinelRef : ( el : HTMLElement | null ) => void ;
101115 /** Ref callback for the invisible sentinel placed below the last segment. */
@@ -173,6 +187,7 @@ function buildCenteredRange(anchorIndex: number, total: number): WindowRange {
173187export default function useSegmentWindow ( {
174188 book,
175189 scrRef,
190+ focusedTokenRef,
176191 scrollContainerRef,
177192 internalNavRef,
178193} : UseSegmentWindowArgs ) : UseSegmentWindowResult {
@@ -192,6 +207,16 @@ export default function useSegmentWindow({
192207 */
193208 const [ displayScrRef , setDisplayScrRef ] = useState < SerializedVerseRef > ( scrRef ) ;
194209
210+ /**
211+ * Focused token ref the per-token highlight and link-button active state track. Gated on the same
212+ * clock as {@link displayScrRef}: deferred to the recenter midpoint on external nav (so buttons
213+ * never re-evaluate active/disabled — and dim — on the old, still-visible content before the
214+ * fade-out), updated immediately for internal nav and the initial value.
215+ */
216+ const [ displayFocusedTokenRef , setDisplayFocusedTokenRef ] = useState < string | undefined > (
217+ focusedTokenRef ,
218+ ) ;
219+
195220 /**
196221 * Scroll-height correction owed to the next paint. When segments are prepended the content above
197222 * the viewport grows; recording the pre-mutation `scrollHeight` lets the layout effect restore
@@ -234,6 +259,8 @@ export default function useSegmentWindow({
234259 totalRef . current = total ;
235260 const scrRefRef = useRef ( scrRef ) ;
236261 scrRefRef . current = scrRef ;
262+ const focusedTokenRefRef = useRef ( focusedTokenRef ) ;
263+ focusedTokenRefRef . current = focusedTokenRef ;
237264
238265 /**
239266 * Handle of the in-flight recenter fade timeout, or `undefined` when no recenter is mid-flight.
@@ -355,6 +382,7 @@ export default function useSegmentWindow({
355382 setRange ( buildCenteredRange ( anchorIndexRef . current , totalRef . current ) ) ;
356383 setRecenterEpoch ( ( n ) => n + 1 ) ;
357384 setDisplayScrRef ( scrRefRef . current ) ;
385+ setDisplayFocusedTokenRef ( focusedTokenRefRef . current ) ;
358386 setIsFaded ( false ) ;
359387 } , RECENTER_FADE_MS ) ;
360388 } , [ ] ) ;
@@ -383,6 +411,7 @@ export default function useSegmentWindow({
383411 internalNavRef . current = undefined ;
384412 if ( isInternal ) {
385413 setDisplayScrRef ( currentScrRef ) ;
414+ setDisplayFocusedTokenRef ( focusedTokenRefRef . current ) ;
386415 return ;
387416 }
388417 triggerRecenter ( ) ;
@@ -393,6 +422,19 @@ export default function useSegmentWindow({
393422 // eslint-disable-next-line react-hooks/exhaustive-deps
394423 } , [ anchorIndex , segments , internalNavRef , triggerRecenter ] ) ;
395424
425+ // Track within-verse focus moves (arrow/click that stays in the active verse) immediately. These
426+ // change `focusedTokenRef` without changing `anchorIndex`, so the recenter effect above never
427+ // fires for them; sync the display ref here so the focus highlight follows. Skip while a recenter
428+ // fade is in flight — that swap owns the display ref and lands the new focus at the midpoint, so
429+ // updating here too would move the highlight (and re-dim buttons) on the old content before the
430+ // fade-out completes. `recenterTimeoutRef` is set synchronously by `triggerRecenter`, so it reads
431+ // true even in the same commit the external nav starts the fade — when `isFaded` state is still
432+ // stale.
433+ useEffect ( ( ) => {
434+ if ( recenterTimeoutRef . current !== undefined ) return ;
435+ setDisplayFocusedTokenRef ( focusedTokenRef ) ;
436+ } , [ focusedTokenRef ] ) ;
437+
396438 // The mounted sentinel elements, held in state so the observer effect re-runs once they attach.
397439 // Ref callbacks only record the node; the actual observe happens in the effect below, which runs
398440 // after React has attached every ref — including the scroll container (an ancestor). Wiring the
@@ -472,6 +514,7 @@ export default function useSegmentWindow({
472514 windowSegments,
473515 isFaded,
474516 displayScrRef,
517+ displayFocusedTokenRef,
475518 topSentinelRef,
476519 bottomSentinelRef,
477520 recenterOnActive,
0 commit comments