Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6b3d1a0
feat: keep the entity's name and interaction in view while scrolling
jwalkingjew Sep 25, 2026
8745c54
fix: raise the sticky header on a topic page too
jwalkingjew Sep 25, 2026
aed2c83
fix: let the sidebar toggle and the responder faces be clicked
jwalkingjew Sep 25, 2026
fa5d4c0
fix: order the bar's controls and stop it cutting the sidebar rail
jwalkingjew Sep 25, 2026
99ec92e
fix: line the sticky bar up with each page's own content column
jwalkingjew Sep 25, 2026
fcf76c8
test: pin the sticky bar's re-measure on a column or host resize
jwalkingjew Sep 25, 2026
95622b3
fix: review findings on the sticky entity header
jwalkingjew Sep 25, 2026
a14efc6
fix: keep the sticky bar off side-panel titles, and repair the layout…
jwalkingjew Sep 25, 2026
6ba5fa5
fix: read the sheet's popover container where opening re-renders
jwalkingjew Sep 25, 2026
0195060
fix: no stale frame on navigation, no prose in the 48px bar
jwalkingjew Sep 25, 2026
2235915
fix: measure a swapped-in title instead of inheriting the old one's a…
jwalkingjew Sep 25, 2026
f5eda1f
fix: measure the content column before paint, not after it
jwalkingjew Sep 25, 2026
64b6170
test: cover the third prose state compact suppresses
jwalkingjew Sep 26, 2026
6b2f049
Merge origin/master into the sticky entity header
jwalkingjew Sep 26, 2026
4678d7b
fix: keep the vote confirmation announced, and key the bar's media
jwalkingjew Sep 26, 2026
32ca6f2
fix: ignore notifications about a title already dropped
jwalkingjew Sep 26, 2026
912c674
fix: ignore a retired observer, and correct two stale contracts
jwalkingjew Sep 26, 2026
f60deb1
Merge branch 'master' into preston/sticky-entity-header
jwalkingjew Sep 26, 2026
1518529
fix: show a progress cursor on the thumbs while a vote confirms
jwalkingjew Sep 26, 2026
719c79c
fix: make the thumbs behave like the claim pills while a vote confirms
jwalkingjew Sep 26, 2026
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
6 changes: 6 additions & 0 deletions apps/web/app/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -151,6 +152,11 @@ export function App({ children }: { children: React.ReactNode }) {
triggerRef={mobileBrowseButtonRef}
/>
<SearchDialog open={open} onDone={() => setOpen(false)} />
{/* Directly under the navbar and above the page: a zero-height dock the entity route
portals its sticky header into. See `EntityStickyHeaderHost`. The collapsed sidebar
leaves a vertical rail across this column with nothing holding the space — the same
condition that draws it below. */}
<EntityStickyHeaderHost railInset={!sidebarOpen && !fullscreenActive} />
<div className="min-w-0 flex-1 2xl:px-[2ch]">
<Main>{children}</Main>
</div>
Expand Down
14 changes: 13 additions & 1 deletion apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = <EntityStickyHeader entityId={entityId} spaceId={spaceId} />;
Comment thread
jwalkingjew marked this conversation as resolved.

if (entityBrowseViewFromTypes(entityTypes) !== 'person') {
return <>{children}</>;
return (
<>
{stickyHeader}
{children}
</>
);
}

const profile = await getProfilePage(entityId, spaceId);

return (
<EntityStoreProvider id={entityId} spaceId={spaceId}>
{stickyHeader}
<RouteEditorProvider
id={profile.id}
spaceId={spaceId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
heading: null as Record<string, unknown> | null,
storeProvider: null as Record<string, unknown> | null,
metadataHeader: null as Record<string, unknown> | null,
stickyHeader: null as Record<string, unknown> | null,
entityTypes: [] as { id: string }[],
}));

Expand Down Expand Up @@ -83,6 +84,16 @@ vi.mock('~/partials/entity-page/entity-page-metadata-header', () => ({
},
}));

// Stubbed like every other child here: the real one reads the sync engine, which this suite mounts
// no provider for. Its props are captured because they are this file's subject — the header votes
// on and names an entity, so it has to be handed the route's id like the actions and the title.
vi.mock('~/partials/entity-page/entity-sticky-header', () => ({
EntityStickyHeader: (props: Record<string, unknown>) => {
mocks.stickyHeader = props;
return <div data-testid="sticky-header" />;
},
}));

vi.mock('~/partials/entity-page/entity-page-cover', () => ({ EntityPageCover: () => null }));
vi.mock('~/partials/entity-page/entity-page-content-container', () => ({
EntityPageContentContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Expand All @@ -104,6 +115,7 @@ beforeEach(() => {
mocks.heading = null;
mocks.storeProvider = null;
mocks.metadataHeader = null;
mocks.stickyHeader = null;
});

afterEach(cleanup);
Expand Down Expand Up @@ -145,6 +157,8 @@ describe('profile ProfileLayout', () => {
// The metadata header takes its id from the store rather than a prop, so pinning the provider
// above is what covers it — the space it scopes by is the one assertable here.
expect(mocks.metadataHeader).toMatchObject({ spaceId: SPACE_ID });
expect(mocks.stickyHeader).toMatchObject({ entityId: ROUTE_ENTITY_ID, spaceId: SPACE_ID });
expect(mocks.stickyHeader?.entityId).not.toBe(FETCHED_PERSON_ID);
});

it('does not wrap a mixed Claim and Person entity in the profile shell', async () => {
Expand All @@ -155,5 +169,9 @@ describe('profile ProfileLayout', () => {
expect(view.getByTestId('page')).toBeInTheDocument();
expect(mocks.storeProvider).toBeNull();
expect(mocks.actions).toBeNull();
// The bar is not part of the profile shell: it belongs to every entity route, so the branch that
// draws no shell still draws it.
expect(view.getByTestId('sticky-header')).toBeInTheDocument();
expect(mocks.stickyHeader).toMatchObject({ entityId: ROUTE_ENTITY_ID, spaceId: SPACE_ID });
});
});
11 changes: 11 additions & 0 deletions apps/web/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export const entitySidePanelAtom = atom<EntitySidePanelTarget | null>(null);

export const entitySidePanelHostElementAtom = atom<HTMLElement | null>(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 `<main>` —
* 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<HTMLElement | null>(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.
Expand Down
7 changes: 6 additions & 1 deletion apps/web/core/claims/browse/claim-page-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Text } from '~/design-system/text';
import { CommentSection } from '~/partials/comments/comments-section';
import { Editor } from '~/partials/editor/editor';
import { EditableHeading } from '~/partials/entity-page/editable-entity-header';
import { ENTITY_PAGE_CONTENT_ANCHOR, entityPageTitleAnchor } from '~/partials/entity-page/entity-page-anchors';
import {
ENTITY_DESCRIPTION_MAX_LINES,
EntityPageInlineDescription,
Expand Down Expand Up @@ -217,6 +218,7 @@ export function ClaimPageView({
return (
<div className="@container">
<div
{...ENTITY_PAGE_CONTENT_ANCHOR}
className={`mx-auto flex w-full flex-col gap-6 py-6 @[560px]:gap-8 @[560px]:py-8 ${CLAIM_PAGE_CONTENT_INSET_CLASS}`}
style={{ maxWidth: CLAIM_PAGE_CONTENT_MAX_WIDTH }}
>
Expand Down Expand Up @@ -262,7 +264,10 @@ export function ClaimPageView({
{isEditing ? (
<EditableHeading entityId={entityId} spaceId={spaceId} fallbackName={entity.name ?? entity.id} />
) : (
<h1 className="text-[1.5rem] leading-[1.3] font-semibold tracking-[-0.4px] text-pretty text-text @[560px]:text-[1.75rem]">
<h1
{...entityPageTitleAnchor(entityId)}
className="text-[1.5rem] leading-[1.3] font-semibold tracking-[-0.4px] text-pretty text-text @[560px]:text-[1.75rem]"
Comment thread
jwalkingjew marked this conversation as resolved.
>
{entity.name ?? entity.id}
</h1>
)}
Expand Down
Loading
Loading