{/* Hero */}
{/* `text-pretty`, not `text-balance`. Balancing evens every line to the same length,
which on a claim — a full sentence running to three or four lines — leaves each one
breaking well short of the measure and reads as wrapping early. Pretty only avoids a
stranded last word, so the lines fill. */}
-
- {entity.name ?? entity.id}
-
+ {isEditing ? (
+
+ ) : (
+
+ {entity.name ?? entity.id}
+
+ )}
{/* Clamped, like entity pages and the side panel (GEO-2772). What is shared is the line
budget, not the cut: wrapping differs with width, so the route, the side panel and a
@@ -105,13 +212,21 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId
`ClampedText` measures an unclamped clone, so the toggle appears only when something
is genuinely hidden, and it is unaffected by the naive-overflow bug GEO-2756 fixed in
the feed's own title. */}
- {entity.description && (
-
+ ) : (
+ entity.description && (
+
+ )
)}
{/* What this is. Topics — what it is *about* — used to sit opposite these, pushed to the
@@ -130,41 +245,153 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId
- {/* The topic view's Subtopics, drawing a claim's Topics (GEO-2781) — same question for the
- reader, so the same answer rather than two that look alike until one of them changes.
- Directly under the header, where the topic view puts its own: on a claim the thing worth
- offering before the argument itself is somewhere else to take it. */}
-
-
-
-
-
-
-
-
-
-
-
+
+ tab.label)}
+ divideBeforeAuthored
+ />
+
- {/* Last, and in the same `page` variant a regular entity uses — the entity body renders it
- this way for both the route and the side panel, and only the dedicated comments panel
- asks for the `panel` variant. Unlike the modules above, this one always renders: an
- empty thread is an invitation to start it, not an absence to hide. */}
-
-
-
+ summary={summary}
+ state={state}
+ row={row}
+ record={record}
+ topics={topics}
+ availableSpaceIds={entity.spaces}
+ hrefs={{ debates: hrefs.debates, claims: hrefs.claims }}
+ onSelectSystemTab={sidePanelTab?.setActiveSystemTab}
+ />
+ {footer}
);
}
- const hrefTabId = tabIdFromEntityTabHref(href);
+ const hrefTabId = entityTabIdFromHref(href);
if (sidePanelTab) {
return (
);
}
@@ -248,24 +334,16 @@ function Tab({ href, label, badge, disabled, hidden }: TabProps) {
// preserving the offset for all of them lands a reader who switched tabs
// near the bottom of a long list somewhere past the end of a shorter one.
//
- // What it was added for is real — Next's jump to the top makes the underline
- // fly up through the label, because the shared-layout animation measures the
- // marker before and after and animates through the scroll delta. That wants
- // scrolling *to the tab bar* rather than to the top or not at all, which is
- // a behaviour to design alongside the sticky bar in GEO-2923 rather than a
- // flag to set here.
-
+ // The underline is one sibling owned by `TabGroup`, animated with x + width only. A shared
+ // layout marker measured the page's vertical scroll between routes and flew through the label.
+
{label}
{badge && {badge}}
- {active && (
-
- )}
);
}
diff --git a/apps/web/partials/entity-page/custom-browse-view.test.ts b/apps/web/partials/entity-page/custom-browse-view.test.ts
index 926dfc65bd..5f46f532f4 100644
--- a/apps/web/partials/entity-page/custom-browse-view.test.ts
+++ b/apps/web/partials/entity-page/custom-browse-view.test.ts
@@ -107,10 +107,13 @@ describe('customBrowseView', () => {
expect(view({ entity: { types: [PERSON, CLAIM_TYPE] } })).toBe('claim');
});
- it('falls through to the generic page while editing', () => {
- // These are read surfaces with no property editor behind them, so an editor
- // who lost the value sheet would have no way to change the entity.
+ it('keeps the claim surface while editing', () => {
+ expect(view({ entity: { types: [CLAIM_TYPE] }, isEditing: true })).toBe('claim');
+ });
+
+ it('falls through to the generic page for other custom views while editing', () => {
expect(view({ isEditing: true })).toBe('generic');
+ expect(view({ entity: { types: [TOPIC_TYPE] }, isEditing: true })).toBe('generic');
});
it('draws nothing until the entity itself is known', () => {
diff --git a/apps/web/partials/entity-page/custom-browse-view.ts b/apps/web/partials/entity-page/custom-browse-view.ts
index 64b7eb98a7..c47e594e5b 100644
--- a/apps/web/partials/entity-page/custom-browse-view.ts
+++ b/apps/web/partials/entity-page/custom-browse-view.ts
@@ -31,7 +31,6 @@ export function customBrowseView({
isLoadingSpace: boolean;
isEditing: boolean;
}): CustomBrowseView {
- if (isEditing) return 'generic';
// The types decide which page this is, so until they are known there is no page to draw. Falling
// through to the generic one meanwhile rendered the value sheet for a claim or a topic and then
// replaced it a moment later, which read as the page loading twice.
@@ -40,6 +39,7 @@ export function customBrowseView({
const byType = viewFromTypes(entity);
if (byType === 'claim') return 'claim';
+ if (isEditing) return 'generic';
if (byType === 'topic') return 'topic';
/*
diff --git a/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx b/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx
index 89ab2aa603..4a7befa912 100644
--- a/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx
+++ b/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx
@@ -57,6 +57,7 @@ export const EditableCoverAvatarHeader = ({
coverUrl,
compact = false,
withAvatar = false,
+ contentInsetClassName,
}: {
avatarUrl: string | null;
/**
@@ -65,6 +66,8 @@ export const EditableCoverAvatarHeader = ({
* — a profile — passes the wider with-sidebar width instead.
*/
contentMaxWidth?: number;
+ /** Responsive inline padding inside the aligned content column. */
+ contentInsetClassName?: string;
coverUrl: string | null;
compact?: boolean;
/** Whether the compact header shows the avatar too — see the `compact` branch. */
@@ -106,7 +109,7 @@ export const EditableCoverAvatarHeader = ({
if (!hasCoverImage && !(withAvatar && hasAvatar)) return null;
return (
-
+
{hasCoverImage && (
@@ -150,7 +153,7 @@ export const EditableCoverAvatarHeader = ({
initial={false}
animate={layout}
transition={TRANSITION}
- className={`relative mx-auto w-full ${mobileCoverHeightClass} ${mobileCoverAvatarMarginClass}`}
+ className={`@container relative mx-auto w-full ${mobileCoverHeightClass} ${mobileCoverAvatarMarginClass}`}
>
{/* Cover — fixed size, fades in/out. The inner div clips it via overflow-hidden
so during the height animation the cover is revealed, not scaled. */}
@@ -201,6 +204,7 @@ export const EditableCoverAvatarHeader = ({
// one band of widths.
className={cx(
'absolute right-0 left-0 mx-auto flex justify-start',
+ contentInsetClassName,
isWideColumn &&
'max-w-[var(--entity-page-with-sidebar-max-width)] lg:max-w-[var(--entity-page-content-max-width)]'
)}
diff --git a/apps/web/partials/entity-page/editable-entity-page.tsx b/apps/web/partials/entity-page/editable-entity-page.tsx
index 8d031d9d33..d89a4932d6 100644
--- a/apps/web/partials/entity-page/editable-entity-page.tsx
+++ b/apps/web/partials/entity-page/editable-entity-page.tsx
@@ -44,7 +44,6 @@ import { Checkbox, getChecked } from '~/design-system/checkbox';
import { LinkableMediaChip } from '~/design-system/chip';
import { DateField } from '~/design-system/editable-fields/date-field';
import {
-
PageImageField,
PageStringField,
PageVideoField,
diff --git a/apps/web/partials/entity-page/editable-tab-group.test.tsx b/apps/web/partials/entity-page/editable-tab-group.test.tsx
new file mode 100644
index 0000000000..a25dbb89dc
--- /dev/null
+++ b/apps/web/partials/entity-page/editable-tab-group.test.tsx
@@ -0,0 +1,96 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+
+import React from 'react';
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { type EditableTab, EditableTabGroup } from './editable-tab-group';
+
+const mocks = vi.hoisted(() => ({
+ activeTabId: 'tab-1' as string | null,
+ router: {
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ },
+}));
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/claim',
+ useRouter: () => mocks.router,
+}));
+
+vi.mock('~/core/state/editor/editor-provider', () => ({
+ useActiveTabIdForEditor: () => mocks.activeTabId,
+}));
+
+vi.mock('~/core/state/entity-side-panel-active-tab', () => ({
+ useEntitySidePanelActiveTab: () => null,
+}));
+
+vi.mock('~/core/sync/use-mutate', () => ({
+ useMutate: () => ({
+ storage: {
+ entities: { name: { set: vi.fn() } },
+ relations: { set: vi.fn(), update: vi.fn(), deleteMany: vi.fn() },
+ values: { deleteMany: vi.fn() },
+ },
+ }),
+}));
+
+vi.mock('~/core/sync/use-store', () => ({
+ getRelations: () => [],
+ getValues: () => [],
+}));
+
+vi.mock('~/design-system/prefetch-link', async () => {
+ const React = await import('react');
+
+ return {
+ PrefetchLink: React.forwardRef<
+ HTMLAnchorElement,
+ React.AnchorHTMLAttributes & { prefetch?: boolean }
+ >(function MockPrefetchLink({ children, href, prefetch, ...props }, ref) {
+ return (
+
+ {children}
+
+ );
+ }),
+ };
+});
+
+afterEach(cleanup);
+
+describe('EditableTabGroup active indicator', () => {
+ it('keeps one row-owned indicator outside the active sortable tab', async () => {
+ const editableTabs: EditableTab[] = [
+ {
+ relation: {
+ id: 'relation-1',
+ entityId: 'relation-entity-1',
+ spaceId: 'space-1',
+ position: '1',
+ } as EditableTab['relation'],
+ entityId: 'tab-1',
+ name: 'Authored tab',
+ href: '/claim?tabId=tab-1',
+ },
+ ];
+
+ render();
+
+ // dnd-kit's sortable attributes intentionally give this anchor the rendered role `button` so
+ // keyboard users can pick it up. Assert both halves of that contract: its accessible role and
+ // its real navigation target. Querying `link` would not match the DOM rendered in production.
+ const activeLink = screen.getByRole('button', { name: 'Authored tab' });
+ expect(activeLink).toHaveAttribute('href', '/claim?tabId=tab-1');
+
+ await waitFor(() => expect(document.querySelectorAll('[data-active-tab-indicator]')).toHaveLength(1));
+
+ const indicator = document.querySelector('[data-active-tab-indicator]');
+ expect(activeLink).not.toContainElement(indicator);
+ expect(indicator?.parentElement).toBe(activeLink.parentElement?.parentElement);
+ });
+});
diff --git a/apps/web/partials/entity-page/editable-tab-group.tsx b/apps/web/partials/entity-page/editable-tab-group.tsx
index a8a37c9d3e..f3c3aa5aad 100644
--- a/apps/web/partials/entity-page/editable-tab-group.tsx
+++ b/apps/web/partials/entity-page/editable-tab-group.tsx
@@ -17,7 +17,6 @@ import * as Popover from '@radix-ui/react-popover';
import React, { useEffect, useRef, useState } from 'react';
-import { cva } from 'class-variance-authority';
import cx from 'classnames';
import { usePathname, useRouter } from 'next/navigation';
@@ -27,47 +26,22 @@ import { useEntitySidePanelActiveTab } from '~/core/state/entity-side-panel-acti
import { useMutate } from '~/core/sync/use-mutate';
import { getRelations, getValues } from '~/core/sync/use-store';
import type { Relation } from '~/core/types';
-import { NavUtils, validateEntityId } from '~/core/utils/utils';
+import { entityTabIdFromHref, isEntityTabActive } from '~/core/utils/entity-tab-navigation';
+import { NavUtils } from '~/core/utils/utils';
import { EditSmall } from '~/design-system/icons/edit-small';
import { ExpandSmall } from '~/design-system/icons/expand-small';
import { Menu } from '~/design-system/icons/menu';
import { Trash } from '~/design-system/icons/trash';
import { PrefetchLink as Link } from '~/design-system/prefetch-link';
+import {
+ ActiveTabIndicator,
+ type TabGroupTab,
+ tabGroupTabLinkStyles,
+ useActiveTabIndicator,
+} from '~/design-system/tab-group';
-function tabIdFromEntityTabHref(href: string): string | null {
- const idx = href.indexOf('tabId=');
- if (idx === -1) return null;
- const raw = href.slice(idx + 6).split('&')[0];
- return validateEntityId(raw) ? raw : null;
-}
-
-function isEntityTabHrefActive(
- href: string,
- activeTabId: string | null,
- sidePanel: boolean,
- fullPath: string
-): boolean {
- if (!sidePanel) return href === fullPath;
- const hrefTabId = tabIdFromEntityTabHref(href);
- if (hrefTabId === null) return activeTabId === null;
- return activeTabId === hrefTabId;
-}
-
-export type SystemTab = {
- label: string;
- href: string;
- /**
- * Only shown where the side rail is not.
- *
- * Desktop-first breakpoints, so `lg:` applies at 1023px and below —
- * `StickySideRail` drops itself at exactly that width. A tab whose page only
- * repeats the rail has to disappear when the rail comes back, in edit mode as
- * well as out of it: `about/page.tsx` hides its body on a wide screen, so
- * leaving the tab there is a link to an empty column.
- */
- onlyWhenNarrow?: boolean;
-};
+export type SystemTab = Pick;
export type EditableTab = {
relation: Relation;
@@ -86,21 +60,6 @@ type EditableTabGroupProps = {
className?: string;
};
-const tabStyles = cva(
- 'relative z-10 flex items-center gap-1.5 text-quoteMedium whitespace-nowrap transition-colors duration-100',
- {
- variants: {
- active: {
- true: 'text-text',
- false: 'text-grey-04 hover:text-text',
- },
- },
- defaultVariants: {
- active: false,
- },
- }
-);
-
export function EditableTabGroup({
entityId,
spaceId,
@@ -303,6 +262,12 @@ export function EditableTabGroup({
// Key the memo on a joined string so we only allocate a new array when the id set actually changes.
const sortableIdsKey = editableTabs.map(t => t.relation.id).join(',');
const sortableIds = React.useMemo(() => (sortableIdsKey === '' ? [] : sortableIdsKey.split(',')), [sortableIdsKey]);
+ const indicatorLayoutKey = [
+ ...systemTabsBefore.map(tab => `${tab.href}:${tab.label}`),
+ ...editableTabs.map(tab => `${tab.relation.id}:${tab.name}`),
+ ...systemTabsAfter.map(tab => `${tab.href}:${tab.label}`),
+ ].join('|');
+ const { indicator, registerActiveTab } = useActiveTabIndicator(indicatorLayoutKey);
return (
) : null}
@@ -399,12 +392,14 @@ function StaticTab({
active,
onSelect,
onlyWhenNarrow,
+ activeRef,
}: {
href: string;
label: string;
active: boolean;
onSelect?: () => void;
onlyWhenNarrow?: boolean;
+ activeRef: (element: HTMLElement | null) => void;
}) {
// `contents` rather than `block`, so the wrapper does not become a flex item
// between the tabs and pull them apart. The same shape `TabGroup` uses.
@@ -412,17 +407,20 @@ function StaticTab({
if (onSelect) {
return wrap(
-