From bced5bbb4338f6abe310ce427065e6ad3e78bfc9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 23:33:42 +0000 Subject: [PATCH 1/4] fix(web): ignore settings section slivers when highlighting navigation --- .../settings/settingsSectionVisibility.ts | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/settingsSectionVisibility.ts b/apps/web/src/components/settings/settingsSectionVisibility.ts index 76b1fc943256..37003701bb91 100644 --- a/apps/web/src/components/settings/settingsSectionVisibility.ts +++ b/apps/web/src/components/settings/settingsSectionVisibility.ts @@ -59,11 +59,52 @@ function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { return target && root.contains(target) ? target : null; }, createIntersectionObserver(onEntries, scrollRoot) { - const observer = new IntersectionObserver(onEntries, { - root: scrollRoot, - threshold: 0, + const observers = new Map(); + const updateObserver = (target: Element) => { + // Require half a section, capped at a quarter of the viewport for tall sections. + const threshold = Math.min( + 0.5, + (scrollRoot.clientHeight * 0.25) / Math.max(1, target.getBoundingClientRect().height), + ); + const previous = observers.get(target); + if (previous?.threshold === threshold) return; + previous?.observer.disconnect(); + const observer = new IntersectionObserver( + (entries) => { + if (observers.get(target)?.observer !== observer) return; + onEntries( + entries.map((entry) => ({ + target: entry.target, + intersectionRatio: entry.intersectionRatio, + isIntersecting: entry.isIntersecting && entry.intersectionRatio >= threshold, + })), + ); + }, + { root: scrollRoot, threshold }, + ); + observers.set(target, { observer, threshold }); + observer.observe(target); + }; + const resizeObserver = new ResizeObserver(() => { + for (const target of observers.keys()) updateObserver(target); }); - return observer; + resizeObserver.observe(scrollRoot); + return { + observe(target) { + updateObserver(target); + resizeObserver.observe(target); + }, + unobserve(target) { + observers.get(target)?.observer.disconnect(); + observers.delete(target); + resizeObserver.unobserve(target); + }, + disconnect() { + resizeObserver.disconnect(); + for (const { observer } of observers.values()) observer.disconnect(); + observers.clear(); + }, + }; }, createMutationObserver(onMutation, container) { const observer = new MutationObserver(onMutation); From cfcadac7b7864e80d825dfa708bac0ead0b205d3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 23:48:34 +0000 Subject: [PATCH 2/4] fix(web): focus settings navigation on the centered section --- .../settings/settingsSectionVisibility.ts | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/settings/settingsSectionVisibility.ts b/apps/web/src/components/settings/settingsSectionVisibility.ts index 37003701bb91..1afb3f996fca 100644 --- a/apps/web/src/components/settings/settingsSectionVisibility.ts +++ b/apps/web/src/components/settings/settingsSectionVisibility.ts @@ -59,50 +59,56 @@ function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { return target && root.contains(target) ? target : null; }, createIntersectionObserver(onEntries, scrollRoot) { - const observers = new Map(); - const updateObserver = (target: Element) => { - // Require half a section, capped at a quarter of the viewport for tall sections. - const threshold = Math.min( - 0.5, - (scrollRoot.clientHeight * 0.25) / Math.max(1, target.getBoundingClientRect().height), + const targets = new Set(); + let frame: number | null = null; + let stopped = false; + const measure = () => { + frame = null; + const top = scrollRoot.getBoundingClientRect().top + scrollRoot.clientTop; + const bottom = top + scrollRoot.clientHeight; + const center = (top + bottom) / 2; + let activeTarget: Element | null = null; + let nearestDistance = Infinity; + for (const target of targets) { + const bounds = target.getBoundingClientRect(); + if (Math.min(bounds.bottom, bottom) <= Math.max(bounds.top, top)) continue; + const distance = Math.max(bounds.top - center, center - bounds.bottom, 0); + if (distance < nearestDistance) { + activeTarget = target; + nearestDistance = distance; + } + } + onEntries( + [...targets].map((target) => ({ + target, + intersectionRatio: target === activeTarget ? 1 : 0, + isIntersecting: target === activeTarget, + })), ); - const previous = observers.get(target); - if (previous?.threshold === threshold) return; - previous?.observer.disconnect(); - const observer = new IntersectionObserver( - (entries) => { - if (observers.get(target)?.observer !== observer) return; - onEntries( - entries.map((entry) => ({ - target: entry.target, - intersectionRatio: entry.intersectionRatio, - isIntersecting: entry.isIntersecting && entry.intersectionRatio >= threshold, - })), - ); - }, - { root: scrollRoot, threshold }, - ); - observers.set(target, { observer, threshold }); - observer.observe(target); }; - const resizeObserver = new ResizeObserver(() => { - for (const target of observers.keys()) updateObserver(target); - }); + const scheduleMeasure = () => { + if (!stopped && frame === null) frame = requestAnimationFrame(measure); + }; + const resizeObserver = new ResizeObserver(scheduleMeasure); resizeObserver.observe(scrollRoot); + scrollRoot.addEventListener("scroll", scheduleMeasure, { passive: true }); return { observe(target) { - updateObserver(target); + targets.add(target); resizeObserver.observe(target); + scheduleMeasure(); }, unobserve(target) { - observers.get(target)?.observer.disconnect(); - observers.delete(target); + targets.delete(target); resizeObserver.unobserve(target); + scheduleMeasure(); }, disconnect() { + stopped = true; + if (frame !== null) cancelAnimationFrame(frame); + scrollRoot.removeEventListener("scroll", scheduleMeasure); resizeObserver.disconnect(); - for (const { observer } of observers.values()) observer.disconnect(); - observers.clear(); + targets.clear(); }, }; }, From 9736c684711b277ac51f76944a7b3bd43dcb3ff6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 8 Sep 2026 00:00:49 +0000 Subject: [PATCH 3/4] fix(web): softly highlight visible settings sections --- .../settings/SettingsSidebarNav.tsx | 11 ++++-- .../settingsSectionVisibility.test.ts | 27 ++++++++++++-- .../settings/settingsSectionVisibility.ts | 36 +++++++++++++++---- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 9547a14c8bdd..2c61bb69536a 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -183,8 +183,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { return observeSettingsSectionVisibility({ container, targetIds: observedVisibilityScope.pageSections.map((section) => section.targetId), - onChange(targetIds) { - setSectionVisibility({ scope: observedVisibilityScope, targetIds: new Set(targetIds) }); + onChange(targetIds, activeTargetId) { + setSectionVisibility({ + scope: observedVisibilityScope, + targetIds: new Set(targetIds), + activeTargetId, + }); }, }); }, [observedVisibilityScope]); @@ -439,6 +443,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { className={cn( "w-full text-sidebar-muted-foreground/65", visiblePageSectionIds.has(section.targetId) && + "text-sidebar-foreground/65", + visiblePageSectionIds.has(section.targetId) && + sectionVisibility?.activeTargetId === section.targetId && "font-medium text-sidebar-foreground", )} onClick={() => handlePageSectionClick(item.to, section.targetId)} diff --git a/apps/web/src/components/settings/settingsSectionVisibility.test.ts b/apps/web/src/components/settings/settingsSectionVisibility.test.ts index 90656ad7073a..7da9141cc136 100644 --- a/apps/web/src/components/settings/settingsSectionVisibility.test.ts +++ b/apps/web/src/components/settings/settingsSectionVisibility.test.ts @@ -9,7 +9,7 @@ import { type VisibilityEntry = Pick< IntersectionObserverEntry, "intersectionRatio" | "isIntersecting" | "target" ->; +> & { readonly active?: boolean }; function createHarness( targetIds: ReadonlyArray, @@ -75,11 +75,34 @@ function visibleEntry( } describe("settings section visibility", () => { + it("moves the centered highlight while keeping other visible sections", () => { + const harness = createHarness(["one", "two"]); + const onChange = vi.fn(); + observeSettingsSectionVisibility({ + container: {} as Element, + targetIds: ["one", "two"], + onChange, + environment: harness.environment, + }); + const one = harness.targets.get("one")!; + const two = harness.targets.get("two")!; + harness.intersect([{ ...visibleEntry(one), active: true }, visibleEntry(two)]); + expect(onChange).toHaveBeenLastCalledWith(["one", "two"], "one"); + harness.intersect([visibleEntry(one), { ...visibleEntry(two), active: true }]); + expect(onChange).toHaveBeenLastCalledWith(["one", "two"], "two"); + harness.intersect([visibleEntry(one), { ...visibleEntry(two), active: true }]); + expect(onChange).toHaveBeenCalledTimes(3); + harness.targets.delete("two"); + harness.mutate(); + expect(onChange).toHaveBeenLastCalledWith(["one"], null); + }); + it("does not reuse visibility when returning to the same sectioned route", () => { const firstGeneralVisit = { path: "/settings/general" }; const firstVisibility = { scope: firstGeneralVisit, targetIds: new Set(["text-generation"]), + activeTargetId: "text-generation", }; expect( @@ -240,6 +263,6 @@ describe("settings section visibility", () => { expect(harness.disconnectIntersections).toHaveBeenCalledOnce(); expect(harness.disconnectMutations).toHaveBeenCalledOnce(); expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenLastCalledWith([]); + expect(onChange).toHaveBeenLastCalledWith([], null); }); }); diff --git a/apps/web/src/components/settings/settingsSectionVisibility.ts b/apps/web/src/components/settings/settingsSectionVisibility.ts index 1afb3f996fca..3e5739c7cfae 100644 --- a/apps/web/src/components/settings/settingsSectionVisibility.ts +++ b/apps/web/src/components/settings/settingsSectionVisibility.ts @@ -1,7 +1,7 @@ type VisibilityEntry = Pick< IntersectionObserverEntry, "intersectionRatio" | "isIntersecting" | "target" ->; +> & { readonly active?: boolean }; export type SettingsSectionVisibilityScope = { readonly path: string; @@ -10,6 +10,7 @@ export type SettingsSectionVisibilityScope = { export type SettingsSectionVisibilityState = { readonly scope: SettingsSectionVisibilityScope; readonly targetIds: ReadonlySet; + readonly activeTargetId: string | null; }; const EMPTY_VISIBLE_SETTINGS_SECTION_IDS: ReadonlySet = new Set(); @@ -69,9 +70,12 @@ function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { const center = (top + bottom) / 2; let activeTarget: Element | null = null; let nearestDistance = Infinity; + const visibleRatios = new Map(); for (const target of targets) { const bounds = target.getBoundingClientRect(); - if (Math.min(bounds.bottom, bottom) <= Math.max(bounds.top, top)) continue; + const overlap = Math.min(bounds.bottom, bottom) - Math.max(bounds.top, top); + if (overlap <= 0) continue; + visibleRatios.set(target, overlap / bounds.height); const distance = Math.max(bounds.top - center, center - bounds.bottom, 0); if (distance < nearestDistance) { activeTarget = target; @@ -81,8 +85,9 @@ function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { onEntries( [...targets].map((target) => ({ target, - intersectionRatio: target === activeTarget ? 1 : 0, - isIntersecting: target === activeTarget, + intersectionRatio: visibleRatios.get(target) ?? 0, + isIntersecting: visibleRatios.has(target), + active: target === activeTarget, })), ); }; @@ -128,13 +133,17 @@ export function observeSettingsSectionVisibility({ }: { readonly container: Element; readonly targetIds: ReadonlyArray; - readonly onChange: (visibleTargetIds: ReadonlyArray) => void; + readonly onChange: ( + visibleTargetIds: ReadonlyArray, + activeTargetId: string | null, + ) => void; readonly environment?: SettingsSectionVisibilityEnvironment; }): () => void { const orderedTargetIds = [...new Set(targetIds)]; const targetsById = new Map(); const targetIdsByElement = new Map(); const visibleTargetIds = new Set(); + let activeTargetId: string | null = null; let lastEmission: string | null = null; let stopped = false; let root: Element | null = null; @@ -143,10 +152,10 @@ export function observeSettingsSectionVisibility({ const emit = () => { const visibleInOrder = orderedTargetIds.filter((targetId) => visibleTargetIds.has(targetId)); - const emissionKey = visibleInOrder.join("\0"); + const emissionKey = JSON.stringify([visibleInOrder, activeTargetId]); if (emissionKey === lastEmission) return; lastEmission = emissionKey; - onChange(visibleInOrder); + onChange(visibleInOrder, activeTargetId); }; const handleEntries = (entries: ReadonlyArray, generation: number) => { @@ -156,6 +165,13 @@ export function observeSettingsSectionVisibility({ const targetId = targetIdsByElement.get(entry.target); if (!targetId || targetsById.get(targetId) !== entry.target) continue; const visible = entry.isIntersecting && entry.intersectionRatio > 0; + if (visible && entry.active) { + changed = activeTargetId !== targetId || changed; + activeTargetId = targetId; + } else if (activeTargetId === targetId) { + activeTargetId = null; + changed = true; + } if (visible === visibleTargetIds.has(targetId)) continue; changed = true; if (visible) { @@ -181,6 +197,7 @@ export function observeSettingsSectionVisibility({ targetIdsByElement.clear(); changed = visibleTargetIds.size > 0; visibleTargetIds.clear(); + activeTargetId = null; if (root) { const generation = observerGeneration; @@ -206,6 +223,10 @@ export function observeSettingsSectionVisibility({ targetsById.delete(targetId); targetIdsByElement.delete(previousTarget); changed = visibleTargetIds.delete(targetId) || changed; + if (activeTargetId === targetId) { + activeTargetId = null; + changed = true; + } } if (nextTarget) { targetsById.set(targetId, nextTarget); @@ -229,5 +250,6 @@ export function observeSettingsSectionVisibility({ targetsById.clear(); targetIdsByElement.clear(); visibleTargetIds.clear(); + activeTargetId = null; }; } From 5cb700f16ae8186afb98e0218f4a7ece876aa6c8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 8 Sep 2026 00:01:56 +0000 Subject: [PATCH 4/4] revert(web): keep only the centered settings section highlighted --- .../settings/SettingsSidebarNav.tsx | 11 ++---- .../settingsSectionVisibility.test.ts | 27 ++------------ .../settings/settingsSectionVisibility.ts | 36 ++++--------------- 3 files changed, 11 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 2c61bb69536a..9547a14c8bdd 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -183,12 +183,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { return observeSettingsSectionVisibility({ container, targetIds: observedVisibilityScope.pageSections.map((section) => section.targetId), - onChange(targetIds, activeTargetId) { - setSectionVisibility({ - scope: observedVisibilityScope, - targetIds: new Set(targetIds), - activeTargetId, - }); + onChange(targetIds) { + setSectionVisibility({ scope: observedVisibilityScope, targetIds: new Set(targetIds) }); }, }); }, [observedVisibilityScope]); @@ -443,9 +439,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { className={cn( "w-full text-sidebar-muted-foreground/65", visiblePageSectionIds.has(section.targetId) && - "text-sidebar-foreground/65", - visiblePageSectionIds.has(section.targetId) && - sectionVisibility?.activeTargetId === section.targetId && "font-medium text-sidebar-foreground", )} onClick={() => handlePageSectionClick(item.to, section.targetId)} diff --git a/apps/web/src/components/settings/settingsSectionVisibility.test.ts b/apps/web/src/components/settings/settingsSectionVisibility.test.ts index 7da9141cc136..90656ad7073a 100644 --- a/apps/web/src/components/settings/settingsSectionVisibility.test.ts +++ b/apps/web/src/components/settings/settingsSectionVisibility.test.ts @@ -9,7 +9,7 @@ import { type VisibilityEntry = Pick< IntersectionObserverEntry, "intersectionRatio" | "isIntersecting" | "target" -> & { readonly active?: boolean }; +>; function createHarness( targetIds: ReadonlyArray, @@ -75,34 +75,11 @@ function visibleEntry( } describe("settings section visibility", () => { - it("moves the centered highlight while keeping other visible sections", () => { - const harness = createHarness(["one", "two"]); - const onChange = vi.fn(); - observeSettingsSectionVisibility({ - container: {} as Element, - targetIds: ["one", "two"], - onChange, - environment: harness.environment, - }); - const one = harness.targets.get("one")!; - const two = harness.targets.get("two")!; - harness.intersect([{ ...visibleEntry(one), active: true }, visibleEntry(two)]); - expect(onChange).toHaveBeenLastCalledWith(["one", "two"], "one"); - harness.intersect([visibleEntry(one), { ...visibleEntry(two), active: true }]); - expect(onChange).toHaveBeenLastCalledWith(["one", "two"], "two"); - harness.intersect([visibleEntry(one), { ...visibleEntry(two), active: true }]); - expect(onChange).toHaveBeenCalledTimes(3); - harness.targets.delete("two"); - harness.mutate(); - expect(onChange).toHaveBeenLastCalledWith(["one"], null); - }); - it("does not reuse visibility when returning to the same sectioned route", () => { const firstGeneralVisit = { path: "/settings/general" }; const firstVisibility = { scope: firstGeneralVisit, targetIds: new Set(["text-generation"]), - activeTargetId: "text-generation", }; expect( @@ -263,6 +240,6 @@ describe("settings section visibility", () => { expect(harness.disconnectIntersections).toHaveBeenCalledOnce(); expect(harness.disconnectMutations).toHaveBeenCalledOnce(); expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenLastCalledWith([], null); + expect(onChange).toHaveBeenLastCalledWith([]); }); }); diff --git a/apps/web/src/components/settings/settingsSectionVisibility.ts b/apps/web/src/components/settings/settingsSectionVisibility.ts index 3e5739c7cfae..1afb3f996fca 100644 --- a/apps/web/src/components/settings/settingsSectionVisibility.ts +++ b/apps/web/src/components/settings/settingsSectionVisibility.ts @@ -1,7 +1,7 @@ type VisibilityEntry = Pick< IntersectionObserverEntry, "intersectionRatio" | "isIntersecting" | "target" -> & { readonly active?: boolean }; +>; export type SettingsSectionVisibilityScope = { readonly path: string; @@ -10,7 +10,6 @@ export type SettingsSectionVisibilityScope = { export type SettingsSectionVisibilityState = { readonly scope: SettingsSectionVisibilityScope; readonly targetIds: ReadonlySet; - readonly activeTargetId: string | null; }; const EMPTY_VISIBLE_SETTINGS_SECTION_IDS: ReadonlySet = new Set(); @@ -70,12 +69,9 @@ function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { const center = (top + bottom) / 2; let activeTarget: Element | null = null; let nearestDistance = Infinity; - const visibleRatios = new Map(); for (const target of targets) { const bounds = target.getBoundingClientRect(); - const overlap = Math.min(bounds.bottom, bottom) - Math.max(bounds.top, top); - if (overlap <= 0) continue; - visibleRatios.set(target, overlap / bounds.height); + if (Math.min(bounds.bottom, bottom) <= Math.max(bounds.top, top)) continue; const distance = Math.max(bounds.top - center, center - bounds.bottom, 0); if (distance < nearestDistance) { activeTarget = target; @@ -85,9 +81,8 @@ function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { onEntries( [...targets].map((target) => ({ target, - intersectionRatio: visibleRatios.get(target) ?? 0, - isIntersecting: visibleRatios.has(target), - active: target === activeTarget, + intersectionRatio: target === activeTarget ? 1 : 0, + isIntersecting: target === activeTarget, })), ); }; @@ -133,17 +128,13 @@ export function observeSettingsSectionVisibility({ }: { readonly container: Element; readonly targetIds: ReadonlyArray; - readonly onChange: ( - visibleTargetIds: ReadonlyArray, - activeTargetId: string | null, - ) => void; + readonly onChange: (visibleTargetIds: ReadonlyArray) => void; readonly environment?: SettingsSectionVisibilityEnvironment; }): () => void { const orderedTargetIds = [...new Set(targetIds)]; const targetsById = new Map(); const targetIdsByElement = new Map(); const visibleTargetIds = new Set(); - let activeTargetId: string | null = null; let lastEmission: string | null = null; let stopped = false; let root: Element | null = null; @@ -152,10 +143,10 @@ export function observeSettingsSectionVisibility({ const emit = () => { const visibleInOrder = orderedTargetIds.filter((targetId) => visibleTargetIds.has(targetId)); - const emissionKey = JSON.stringify([visibleInOrder, activeTargetId]); + const emissionKey = visibleInOrder.join("\0"); if (emissionKey === lastEmission) return; lastEmission = emissionKey; - onChange(visibleInOrder, activeTargetId); + onChange(visibleInOrder); }; const handleEntries = (entries: ReadonlyArray, generation: number) => { @@ -165,13 +156,6 @@ export function observeSettingsSectionVisibility({ const targetId = targetIdsByElement.get(entry.target); if (!targetId || targetsById.get(targetId) !== entry.target) continue; const visible = entry.isIntersecting && entry.intersectionRatio > 0; - if (visible && entry.active) { - changed = activeTargetId !== targetId || changed; - activeTargetId = targetId; - } else if (activeTargetId === targetId) { - activeTargetId = null; - changed = true; - } if (visible === visibleTargetIds.has(targetId)) continue; changed = true; if (visible) { @@ -197,7 +181,6 @@ export function observeSettingsSectionVisibility({ targetIdsByElement.clear(); changed = visibleTargetIds.size > 0; visibleTargetIds.clear(); - activeTargetId = null; if (root) { const generation = observerGeneration; @@ -223,10 +206,6 @@ export function observeSettingsSectionVisibility({ targetsById.delete(targetId); targetIdsByElement.delete(previousTarget); changed = visibleTargetIds.delete(targetId) || changed; - if (activeTargetId === targetId) { - activeTargetId = null; - changed = true; - } } if (nextTarget) { targetsById.set(targetId, nextTarget); @@ -250,6 +229,5 @@ export function observeSettingsSectionVisibility({ targetsById.clear(); targetIdsByElement.clear(); visibleTargetIds.clear(); - activeTargetId = null; }; }