From 6b3d1a0a90e59b39eaf75814a6bf81bae05a740c Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Fri, 25 Sep 2026 09:54:54 -0700 Subject: [PATCH 01/18] feat: keep the entity's name and interaction in view while scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long entity pages lose their subject: by the time you are in the property sheet, the comments or a claim's sources, nothing on screen says which entity you are reading, and voting means scrolling back to the top. Docks a bar under the navbar once the title scrolls away, carrying the name, the avatar or cover if there is one, and the entity's own response control — `EntityVoteButtons` unmodified, so a claim keeps agree/disagree and everything else keeps upvote/downvote without a second place deciding which is which. Mounted once in the `(entity)` layout, above every branch that draws a page, and portalled into a zero-height host in the app shell. The shell is the only place that can express "full width of the content column, under the navbar" — the route renders inside a width-capped, transform-animated `main` where neither `fixed` nor a full-bleed `sticky` behaves — and the host takes no height so appearing costs no layout shift. The title is found by `data-entity-page-title`, not by a ref: four unrelated components draw it (generic header, claim hero, topic hero, profile layout) and only one is mounted at a time. --- apps/web/app/entry.tsx | 4 + .../space/(entity)/[id]/[entityId]/layout.tsx | 14 +- apps/web/atoms/index.ts | 11 + .../core/claims/browse/claim-page-view.tsx | 5 +- .../hooks/use-scrolled-past-element.test.tsx | 189 ++++++++++++++++++ .../core/hooks/use-scrolled-past-element.ts | 92 +++++++++ .../entity-page/editable-entity-header.tsx | 1 + .../entity-page/entity-page-title.tsx | 14 +- .../entity-page/entity-sticky-header-host.tsx | 31 +++ .../entity-page/entity-sticky-header.test.tsx | 146 ++++++++++++++ .../entity-page/entity-sticky-header.tsx | 97 +++++++++ 11 files changed, 600 insertions(+), 4 deletions(-) create mode 100644 apps/web/core/hooks/use-scrolled-past-element.test.tsx create mode 100644 apps/web/core/hooks/use-scrolled-past-element.ts create mode 100644 apps/web/partials/entity-page/entity-sticky-header-host.tsx create mode 100644 apps/web/partials/entity-page/entity-sticky-header.test.tsx create mode 100644 apps/web/partials/entity-page/entity-sticky-header.tsx diff --git a/apps/web/app/entry.tsx b/apps/web/app/entry.tsx index a29ee38a20..088f5de808 100644 --- a/apps/web/app/entry.tsx +++ b/apps/web/app/entry.tsx @@ -26,6 +26,7 @@ import { MobileBrowseDrawer } from '~/partials/browse-sidebar/mobile-browse-draw import { EntityCommentsPanelHost } from '~/partials/comments/entity-comments-panel-host'; import { CreateSpaceDialog } from '~/partials/create-space/create-space-dialog'; import { EntitySidePanel } from '~/partials/entity-page/entity-side-panel'; +import { EntityStickyHeaderHost } from '~/partials/entity-page/entity-sticky-header-host'; import { PersonalProfileCreatePostSidePanelSync } from '~/partials/entity-page/personal-profile-create-post-side-panel-sync'; import { FeatureFlagsDialog } from '~/partials/feature-flags/feature-flags-dialog'; import { GovernanceReopenEditLoadingBar } from '~/partials/governance/governance-reopen-edit-loading-bar'; @@ -156,6 +157,9 @@ export function App({ children }: { children: React.ReactNode }) { triggerRef={mobileBrowseButtonRef} /> setOpen(false)} /> + {/* Directly under the navbar and above the page: a zero-height dock the entity route + portals its sticky header into. See `EntityStickyHeaderHost`. */} +
{children}
diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx index 0a4cd52c86..1c81504f76 100644 --- a/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx +++ b/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx @@ -26,6 +26,7 @@ import { EntityPageContentContainer } from '~/partials/entity-page/entity-page-c import { EntityPageCover } from '~/partials/entity-page/entity-page-cover'; import { EntityPageInlineDescription } from '~/partials/entity-page/entity-page-inline-description'; import { EntityPageMetadataHeader } from '~/partials/entity-page/entity-page-metadata-header'; +import { EntityStickyHeader } from '~/partials/entity-page/entity-sticky-header'; import { EntityTabs } from '~/partials/entity-page/entity-tabs'; import { PersonalProfileSuggestedCard } from '~/partials/entity-page/personal-profile-suggested-card'; import { PersonalProfileSuggestedTaskSync } from '~/partials/entity-page/personal-profile-suggested-task-sync'; @@ -80,14 +81,25 @@ export default async function ProfileLayout(props: Props) { const result = await cachedFetchEntityPage(entityId, spaceId); const entityTypes = result?.entity?.types ?? []; + // Mounted here rather than per page: every entity surface below this — the generic page, a + // claim, a topic, a profile, and each of the type-owned record tabs — hangs off this one layout, + // and the bar has no business being drawn four times with four ideas of what it shows. + const stickyHeader = ; + if (entityBrowseViewFromTypes(entityTypes) !== 'person') { - return <>{children}; + return ( + <> + {stickyHeader} + {children} + + ); } const profile = await getProfilePage(entityId, spaceId); return ( + {stickyHeader} (null); export const entitySidePanelHostElementAtom = atom(null); +/** + * Where the sticky entity header draws itself: a zero-height element docked under the navbar by the + * app shell. + * + * The bar has to span the content column and sit under the navbar, and the entity route that knows + * *which* entity is on screen renders deep inside a width-capped, transform-animated `
` — + * neither a full-bleed `sticky` nor a `fixed` element behaves there. Registering a host once in the + * shell and portalling into it keeps the positioning in the one place that can express it. + */ +export const entityStickyHeaderHostElementAtom = atom(null); + /** * The comments panel's own element, for the same reason the side panel registers one: a slide-up * locks scrolling everywhere but its own subtree, and a panel portalled to the body is outside it. diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 373e2ff61e..45dbb46510 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -258,7 +258,10 @@ export function ClaimPageView({ {isEditing ? ( ) : ( -

+

{entity.name ?? entity.id}

)} diff --git a/apps/web/core/hooks/use-scrolled-past-element.test.tsx b/apps/web/core/hooks/use-scrolled-past-element.test.tsx new file mode 100644 index 0000000000..d5580f308d --- /dev/null +++ b/apps/web/core/hooks/use-scrolled-past-element.test.tsx @@ -0,0 +1,189 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; + +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useScrolledPastElement } from './use-scrolled-past-element'; + +type ObserverRecord = { + callback: IntersectionObserverCallback; + disconnect: Mock<() => void>; + observed: Element[]; + options?: IntersectionObserverInit; + observer: IntersectionObserver; +}; + +let observers: ObserverRecord[] = []; + +const SELECTOR = '[data-entity-page-title="entity-1"]'; +const TOP_OFFSET = 44; + +beforeEach(() => { + observers = []; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = null; + readonly rootMargin = '0px'; + readonly scrollMargin = '0px'; + readonly thresholds = [0]; + private readonly record: ObserverRecord; + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.record = { callback, disconnect: vi.fn(), observed: [], options, observer: this }; + observers.push(this.record); + } + + observe(element: Element) { + this.record.observed.push(element); + } + + unobserve(element: Element) { + this.record.observed = this.record.observed.filter(observed => observed !== element); + } + + disconnect() { + this.record.disconnect(); + this.record.observed = []; + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); +}); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); +}); + +function addTitle(id = 'entity-1') { + const title = document.createElement('h1'); + title.setAttribute('data-entity-page-title', id); + document.body.append(title); + return title; +} + +function latestObserver() { + const record = observers.at(-1); + if (!record) throw new Error('Expected an IntersectionObserver to have been created'); + return record; +} + +/** One notification, with the geometry that separates "above the fold" from "below it". */ +function notify(record: ObserverRecord, target: Element, ...entries: { isIntersecting: boolean; bottom: number }[]) { + act(() => { + record.callback( + entries.map( + ({ isIntersecting, bottom }, index) => + ({ + target, + time: index, + isIntersecting, + intersectionRatio: isIntersecting ? 1 : 0, + boundingClientRect: { bottom } as DOMRectReadOnly, + }) as IntersectionObserverEntry + ), + record.observer + ); + }); +} + +function render(enabled = true) { + return renderHook(() => useScrolledPastElement({ selector: SELECTOR, topOffset: TOP_OFFSET, enabled })); +} + +describe('useScrolledPastElement', () => { + it('observes the matching element below the docked offset', () => { + const title = addTitle(); + render(); + + const record = latestObserver(); + expect(record.observed).toEqual([title]); + expect(record.options).toEqual({ root: null, rootMargin: `-${TOP_OFFSET}px 0px 0px 0px`, threshold: 0 }); + }); + + it('is true once the element has left the viewport upwards', () => { + const title = addTitle(); + const { result } = render(); + + expect(result.current).toBe(false); + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current).toBe(true); + }); + + it('stays false for an element that is merely out of view below the fold', () => { + const title = addTitle(); + const { result } = render(); + + // Not intersecting, but still ahead of the reader — raising the bar here would announce a + // title nobody has scrolled to yet. + notify(latestObserver(), title, { isIntersecting: false, bottom: 2400 }); + expect(result.current).toBe(false); + }); + + it('goes back to false when the element scrolls into view again', () => { + const title = addTitle(); + const { result } = render(); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current).toBe(true); + + notify(latestObserver(), title, { isIntersecting: true, bottom: 200 }); + expect(result.current).toBe(false); + }); + + it('follows the last transition when several are batched', () => { + const title = addTitle(); + const { result } = render(); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }, { isIntersecting: true, bottom: 200 }); + expect(result.current).toBe(false); + }); + + it('picks up a title that mounts after it starts watching', async () => { + const { result } = render(); + expect(latestObserver().observed).toEqual([]); + + const title = addTitle(); + await waitFor(() => expect(latestObserver().observed).toEqual([title])); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current).toBe(true); + }); + + it('clears when the watched title is removed from the document', async () => { + const title = addTitle(); + const { result } = render(); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current).toBe(true); + + act(() => title.remove()); + await waitFor(() => expect(result.current).toBe(false)); + }); + + it('ignores a title belonging to some other entity', () => { + addTitle('entity-2'); + render(); + + expect(latestObserver().observed).toEqual([]); + }); + + it('watches nothing while disabled', () => { + addTitle(); + const { result } = render(false); + + expect(observers).toHaveLength(0); + expect(result.current).toBe(false); + }); + + it('stays false when IntersectionObserver is unavailable', () => { + vi.stubGlobal('IntersectionObserver', undefined); + addTitle(); + + expect(render().result.current).toBe(false); + }); +}); diff --git a/apps/web/core/hooks/use-scrolled-past-element.ts b/apps/web/core/hooks/use-scrolled-past-element.ts new file mode 100644 index 0000000000..ab57f47300 --- /dev/null +++ b/apps/web/core/hooks/use-scrolled-past-element.ts @@ -0,0 +1,92 @@ +'use client'; + +import * as React from 'react'; + +type Options = { + /** CSS selector for the element to watch. Re-queried whenever it leaves the document. */ + selector: string; + /** How far down the viewport counts as "still visible" — the height of whatever is docked above. */ + topOffset: number; + enabled?: boolean; +}; + +/** + * Whether the element matching `selector` has scrolled up out of view, above `topOffset`. + * + * Deliberately selector-based rather than ref-based. The thing being watched — an entity's title — + * is drawn by four unrelated branches (the generic header, the claim hero, the topic hero, the + * profile layout), and the thing that wants the answer is mounted once, above all of them, so there + * is no ref to hand between the two without threading a context through every branch. + * + * `isIntersecting` alone cannot answer this: an element below the fold and an element scrolled off + * the top both read false. The sign of `boundingClientRect.bottom` is what separates them, and only + * "above" counts — a title the reader has not reached yet must not raise the bar. + * + * The target arrives late and can be replaced: this app paints nothing until hydration, and moving + * between an entity's tabs swaps the subtree. A `MutationObserver` covers both, guarded on + * `isConnected` so the usual case is a boolean test rather than a document query per mutation. + * + * Answers `false` where `IntersectionObserver` is missing (jsdom, older browsers). The safe failure + * for a decoration is to stay out of the way. + */ +export function useScrolledPastElement({ selector, topOffset, enabled = true }: Options): boolean { + const [scrolledPast, setScrolledPast] = React.useState(false); + + React.useEffect(() => { + if (!enabled) { + setScrolledPast(false); + return; + } + + if (typeof document === 'undefined' || typeof IntersectionObserver === 'undefined') return; + + let target: Element | null = null; + + const observer = new IntersectionObserver( + entries => { + // Entries are queued chronologically and several transitions can arrive in one batch, so a + // reversible state has to follow the last of them rather than any earlier one. + const latest = entries.at(-1); + if (!latest) return; + setScrolledPast(!latest.isIntersecting && latest.boundingClientRect.bottom <= topOffset); + }, + // Shrinking the root's top edge by the docked height makes "visible" mean "visible below the + // bar", which is what the reader actually sees. + { root: null, rootMargin: `-${topOffset}px 0px 0px 0px`, threshold: 0 } + ); + + const sync = () => { + if (target?.isConnected) return; + + const next = document.querySelector(selector); + if (next === target) return; + + if (target) observer.unobserve(target); + target = next; + + if (target) { + observer.observe(target); + } else { + // Nothing to watch means nothing to be past. Without this the bar would stay up after the + // title it belongs to was unmounted. + setScrolledPast(false); + } + }; + + sync(); + + if (typeof MutationObserver === 'undefined') { + return () => observer.disconnect(); + } + + const mutations = new MutationObserver(sync); + mutations.observe(document.body, { childList: true, subtree: true }); + + return () => { + mutations.disconnect(); + observer.disconnect(); + }; + }, [selector, topOffset, enabled]); + + return scrolledPast; +} diff --git a/apps/web/partials/entity-page/editable-entity-header.tsx b/apps/web/partials/entity-page/editable-entity-header.tsx index edf563ae6a..ca5a70a7a3 100644 --- a/apps/web/partials/entity-page/editable-entity-header.tsx +++ b/apps/web/partials/entity-page/editable-entity-header.tsx @@ -37,6 +37,7 @@ export function EditableHeading({ return ( +