Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public
- Windows packaging uses NSIS only — WiX `.msi` rejects semver pre-releases like `0.1.0-alpha.1`
- Desktop AI: stop implying Cursor `crsr_` keys are a chat API; clearer network errors; Test saves then probes with visible status
- Empty-workspace onboarding: optional EM name at create, single setup checklist, less Home clutter
- Settings / long-page scroll jank: section nav no longer forces layout on scroll; drop sticky chrome backdrop blur

## [0.1.0-alpha.1] - TBD

Expand Down
87 changes: 63 additions & 24 deletions apps/ui/src/components/PageSectionNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export type SectionNavItem = {
label: string;
};

/**
* Sticky “On this page” links with active-section highlighting.
* Scroll tracking avoids layout thrashing: use IntersectionObserver entry
* geometry only, coalesce updates to animation frames, and skip no-op sets.
*/
export function PageSectionNav({
items,
ariaLabel = "On this page",
Expand All @@ -15,55 +20,89 @@ export function PageSectionNav({
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
const sectionKey = items.map((item) => item.id).join("|");
const clickLockUntil = useRef(0);
const itemsRef = useRef(items);
itemsRef.current = items;

useEffect(() => {
if (items[0]?.id) setActiveId(items[0].id);
}, [sectionKey, items]);
const first = sectionKey.split("|")[0];
if (first) setActiveId(first);
}, [sectionKey]);

useEffect(() => {
const nodes = items
.map((item) => document.getElementById(item.id))
const ids = sectionKey.split("|").filter(Boolean);
const nodes = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => Boolean(el));
if (nodes.length === 0) return;

const ratios = new Map<string, number>();
const tops = new Map<string, number>();
let raf = 0;

const commit = () => {
raf = 0;
if (Date.now() < clickLockUntil.current) return;

const ordered = itemsRef.current;
let bestId = "";
let bestRatio = 0;
for (const item of ordered) {
const ratio = ratios.get(item.id) ?? 0;
if (ratio > bestRatio) {
bestRatio = ratio;
bestId = item.id;
}
}
if (bestId && bestRatio > 0.02) {
setActiveId((prev) => (prev === bestId ? prev : bestId));
return;
}

let aboveId = "";
let aboveTop = -Infinity;
for (const item of ordered) {
const top = tops.get(item.id);
if (top == null) continue;
if (top <= 140 && top >= aboveTop) {
aboveTop = top;
aboveId = item.id;
}
}
if (aboveId) setActiveId((prev) => (prev === aboveId ? prev : aboveId));
};

const observer = new IntersectionObserver(
(entries) => {
if (Date.now() < clickLockUntil.current) return;
for (const entry of entries) {
ratios.set(entry.target.id, entry.isIntersecting ? entry.intersectionRatio : 0);
}
const visible = items
.map((item) => document.getElementById(item.id))
.filter((el): el is HTMLElement => Boolean(el))
.filter((el) => (ratios.get(el.id) ?? 0) > 0.02)
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
if (visible[0]) {
setActiveId(visible[0].id);
return;
const id = entry.target.id;
ratios.set(id, entry.isIntersecting ? entry.intersectionRatio : 0);
// Prefer observer-provided rect — do not call getBoundingClientRect (forces layout).
tops.set(id, entry.boundingClientRect.top);
}
const above = nodes
.filter((n) => n.getBoundingClientRect().top <= 140)
.sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top)[0];
if (above) setActiveId(above.id);
if (!raf) raf = requestAnimationFrame(commit);
},
{
root: null,
rootMargin: "-12% 0px -60% 0px",
threshold: [0, 0.1, 0.25, 0.5, 1],
rootMargin: "-10% 0px -55% 0px",
// Fewer thresholds → fewer callbacks while scrolling.
threshold: [0, 0.25, 0.6, 1],
},
);

for (const node of nodes) observer.observe(node);
return () => observer.disconnect();
}, [sectionKey, items]);
return () => {
if (raf) cancelAnimationFrame(raf);
observer.disconnect();
};
}, [sectionKey]);

function goTo(id: string) {
const el = document.getElementById(id);
if (!el) return;
clickLockUntil.current = Date.now() + 700;
setActiveId(id);
el.scrollIntoView({ behavior: "smooth", block: "start" });
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
}

return (
Expand Down
11 changes: 7 additions & 4 deletions apps/ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,6 @@ a:hover { color: var(--brand-strong); }
gap: var(--space-3);
border-bottom: 1px solid var(--line);
background: var(--sidebar-bg);
backdrop-filter: blur(12px);
}

.topbar-brand {
Expand Down Expand Up @@ -356,7 +355,7 @@ a:hover { color: var(--brand-strong); }
padding-bottom: max(var(--space-6), env(safe-area-inset-bottom));
border-right: 1px solid var(--line);
background: var(--sidebar-bg);
backdrop-filter: blur(12px);
/* Solid sidebar paint — blur while scrolling was a WebView jank source on long pages. */
display: flex;
flex-direction: column;
overflow-y: auto;
Expand Down Expand Up @@ -468,13 +467,18 @@ a:hover { color: var(--brand-strong); }
.settings-section,
.page-section {
scroll-margin-top: 1.25rem;
/* Skip paint/layout for off-screen sections on long pages (Settings, Person, Writing desk). */
content-visibility: auto;
contain-intrinsic-size: auto 24rem;
}
.page-section-nav {
position: sticky;
top: var(--space-6);
align-self: start;
padding: var(--space-3) 0 var(--space-3) var(--space-3);
border-left: 1px solid var(--line);
/* Avoid backdrop-filter here — sticky + blur is expensive while scrolling (esp. WebView). */
background: var(--bg);
}
.page-section-nav-label {
font-size: 0.72rem;
Expand Down Expand Up @@ -533,8 +537,7 @@ a:hover { color: var(--brand-strong); }
padding: var(--space-2) var(--space-4);
border-left: none;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(10px);
background: var(--bg);
}
.page-section-nav-label { display: none; }
.page-section-nav-list {
Expand Down