diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx
index 869a5707b8..31eabac1a3 100644
--- a/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx
+++ b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx
@@ -14,7 +14,7 @@ export type ClaimRecordPageProps = {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
-/** The shared server guard and page shell behind the claim's three record routes. */
+/** The shared server guard and page shell behind the claim's record routes. */
export async function ClaimRecordPage(props: ClaimRecordPageProps) {
const params = await props.params;
const searchParams = await props.searchParams;
diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx
new file mode 100644
index 0000000000..07ef5185e3
--- /dev/null
+++ b/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx
@@ -0,0 +1,5 @@
+import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page';
+
+export default function ClaimTopicsPage(props: ClaimRecordPageProps) {
+ return ;
+}
diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx
index 71efb965b3..66e273f50a 100644
--- a/apps/web/core/claims/browse/claim-page-view.test.tsx
+++ b/apps/web/core/claims/browse/claim-page-view.test.tsx
@@ -13,10 +13,20 @@ import { ClaimPageView, resolveClaimTab } from './claim-page-view';
const mocks = vi.hoisted(() => ({
entity: null as Record | null,
+ /** How many responses the claim has; zero means the hero draws no verdict column. */
+ responseTotal: 11,
+ /** Whether the response counts are still out, which is what the hero reserves its column for. */
+ summaryLoading: false,
+ /** Whether the counts actually answered. False after a terminal failure, not just while loading. */
+ hasCounts: true,
+ /** Drives the one strip that can occupy the hero's first grid row. */
+ isControversial: false,
/** Props the description's clamp received, or null if it rendered no clamp at all. */
clamp: null as Record | null,
/** Props the chip section received, or null if the page rendered none. */
chipSection: null as Record | null,
+ /** Props the Topics tab received, or null if the page rendered none. */
+ topicsTab: null as Record | null,
tabs: null as Record | null,
activity: null as Record | null,
recordTab: null as Record | null,
@@ -80,7 +90,8 @@ vi.mock('~/design-system/clamped-text', () => ({
},
}));
-// Its own suite covers the chips and the expander; here we only need to see what it was handed.
+// Only `META_CHIP_CLASS` is still read from here — the hero's switched-off topics row borrows the
+// chips' shape. The section itself no longer renders on this page at all.
vi.mock('~/partials/entity-page/relation-chip-section', () => ({
META_CHIP_CLASS: 'meta-chip',
RelationChipSection: (props: Record) => {
@@ -89,6 +100,15 @@ vi.mock('~/partials/entity-page/relation-chip-section', () => ({
},
}));
+// The Topics tab is a feed of its own, with its own counts and ordering covered in its own suite.
+// Here we only need to see which topics reached it.
+vi.mock('./claim-topics-tab', () => ({
+ ClaimTopicsTab: (props: Record) => {
+ mocks.topicsTab = props;
+ return ;
+ },
+}));
+
vi.mock('~/core/sync/use-store', () => ({
useQueryEntity: () => ({ entity: mocks.entity, isLoading: false }),
}));
@@ -101,7 +121,10 @@ vi.mock('./use-claim-response-state', () => ({
useClaimResponseState: () => ({
responseKind: 'stance',
summary: {
- isControversial: false,
+ isLoading: mocks.summaryLoading,
+ hasCounts: mocks.hasCounts,
+ total: mocks.responseTotal,
+ isControversial: mocks.isControversial,
viewerDirection: 'positive',
viewerSpaceId: 'viewer-space',
isViewerResponseLoading: true,
@@ -138,10 +161,20 @@ vi.mock('./claim-comment-position', () => ({
},
}));
vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => () => {} }));
+// The prefetching link reaches for the sync engine; the topic chips only need to be links.
+vi.mock('~/design-system/prefetch-link', () => ({
+ PrefetchLink: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+}));
vi.mock('~/core/debates/backfill-readiness-for-held-position', () => ({
useBackfillReadinessForHeldPosition: () => {},
}));
-vi.mock('./claim-verdict', () => ({ ClaimVerdict: () => }));
+vi.mock('~/partials/explore/claim-explore-feed-card', () => ({
+ ClaimVerdictColumn: () => ,
+}));
vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () => }));
vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null }));
vi.mock('./claim-record-tab', () => ({
@@ -185,9 +218,14 @@ function claimEntity(description: string | null) {
}
beforeEach(() => {
+ mocks.responseTotal = 11;
+ mocks.summaryLoading = false;
+ mocks.hasCounts = true;
+ mocks.isControversial = false;
mocks.entity = claimEntity('A description long enough that the page has something to collapse.');
mocks.clamp = null;
mocks.chipSection = null;
+ mocks.topicsTab = null;
mocks.tabs = null;
mocks.activity = null;
mocks.recordTab = null;
@@ -261,16 +299,114 @@ describe('ClaimPageView record', () => {
]);
});
- it('orders Overview as response summary, position, activity, then comments', () => {
+ it('heads the page like an Explore claim card: the claim, the pills, then the verdict column', () => {
+ mocks.entity = {
+ ...mocks.entity,
+ types: [{ id: 'claim-type', name: 'Claim' }],
+ relations: [{ id: 'relation-1', type: { id: TOPICS_PROPERTY_ID }, toEntity: { id: 'topic-1', name: 'Ethics' } }],
+ };
render();
+ const heading = screen.getByRole('heading', { level: 1 });
const position = screen.getByTestId('position');
const verdict = screen.getByTestId('verdict');
+ const tabs = screen.getByTestId('tabs');
+
+ // Topics are on their own tab for now, not above the claim.
+ expect(screen.queryByRole('navigation', { name: 'Topics' })).toBeNull();
+ expect(heading.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(position.compareDocumentPosition(verdict) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(verdict.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ // No type chip: every claim on this page is a Claim.
+ expect(screen.queryByText('Claim')).toBeNull();
+ });
+
+ it('draws no verdict column on a claim nobody has answered, as Explore does', () => {
+ mocks.responseTotal = 0;
+ render();
+
+ expect(screen.queryByTestId('verdict')).toBeNull();
+ expect(screen.getByTestId('position')).toBeInTheDocument();
+ });
+
+ it('keeps both hero tracks while the counts are still out', () => {
+ // `hasVerdict` cannot be true until they answer, so a template derived from it alone painted
+ // one column and then re-wrapped the claim when the second appeared — a shift at the top of
+ // the page on every load.
+ mocks.summaryLoading = true;
+ const { container } = render();
+
+ const grid = container.querySelector('header > div');
+ expect(grid?.className).toContain('grid-cols-[minmax(0,1fr)_220px]');
+ // Reserved, not filled: nothing has said what the verdict is yet.
+ expect(screen.queryByTestId('verdict')).toBeNull();
+ });
+
+ it('keeps the track when the counts fail rather than reading the failure as a zero', () => {
+ // A counts query that exhausts its retries leaves `total` at zero with nothing loading any
+ // more — the shape of an unanswered claim, which is exactly what it is not. Keying the track
+ // off `isLoading` gave it back on that failure and re-wrapped the title anyway.
+ mocks.summaryLoading = false;
+ mocks.hasCounts = false;
+ mocks.responseTotal = 0;
+ const { container } = render();
+
+ expect(container.querySelector('header > div')?.className).toContain('grid-cols-[minmax(0,1fr)_220px]');
+ // Reserved, not filled: there is still no verdict to draw.
+ expect(screen.queryByTestId('verdict')).toBeNull();
+ });
+
+ it('gives the column back once the counts settle on nobody having answered', () => {
+ mocks.responseTotal = 0;
+ const { container } = render();
+
+ expect(container.querySelector('header > div')?.className).toContain('grid-cols-1');
+ });
+
+ it('leaves no empty row above the claim when nothing is drawn there', () => {
+ // The hero pins its parts to explicit rows so the verdict can start on the title's. With the
+ // chips row empty, row 1 is a `gap-y-4` above the claim belonging to a row nothing occupies —
+ // visible in the side panel and at phone widths, where that gap is set.
+ const { container } = render();
+
+ expect(screen.getByRole('heading', { level: 1 }).closest('div')?.className).toContain('row-start-1');
+ expect(container.querySelector('[data-testid="position"]')?.closest('.col-start-1')?.className).toContain(
+ 'row-start-2'
+ );
+ });
+
+ it('moves the rows down again when the claim is controversial', () => {
+ mocks.isControversial = true;
+ render();
+
+ expect(screen.getByRole('heading', { level: 1 }).closest('div')?.className).toContain('row-start-2');
+ });
+
+ it('announces no Topics landmark over a row that is only the Controversial chip', () => {
+ // `SHOW_HERO_TOPICS` is off, so being controversial is the only thing that puts this row on
+ // the page — and it holds one status chip and no links. A navigation landmark named "Topics"
+ // over that is both empty and misnamed to anyone moving through the page by landmark.
+ mocks.isControversial = true;
+ // With topics on the claim, so this is about the row having no *links* rather than the claim
+ // having no topics.
+ mocks.entity = {
+ ...claimEntity('Anything'),
+ relations: [{ id: 'relation-1', type: { id: TOPICS_PROPERTY_ID }, toEntity: { id: 'topic-1', name: 'Ethics' } }],
+ };
+ render();
+
+ expect(screen.getByText('Controversial')).toBeInTheDocument();
+ expect(screen.queryByRole('navigation', { name: 'Topics' })).toBeNull();
+ });
+
+ it('orders Overview as activity, then comments', () => {
+ render();
+
+ const tabs = screen.getByTestId('tabs');
const activity = screen.getByTestId('activity');
const comments = screen.getByTestId('comments');
- expect(verdict.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(position.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(tabs.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(activity.compareDocumentPosition(comments) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
@@ -388,38 +524,46 @@ describe('ClaimPageView comments', () => {
});
});
-// GEO-2781. Topics used to be a run of chips crammed into the header's meta row, capped at three
-// and with a `+N` that only counted. It is now the topic view's Subtopics section, which is the
-// same question asked of the reader and so should not be a second thing that merely looks like it.
+// Topics live on their own tab for now; the hero's row is switched off (`SHOW_HERO_TOPICS`). The
+// Overview no longer repeats them either.
describe('ClaimPageView topics', () => {
- const topicRelation = {
- id: 'relation-1',
+ const topic = (n: number) => ({
+ id: `relation-${n}`,
type: { id: TOPICS_PROPERTY_ID },
- toEntity: { id: 'topic-1', name: 'Ethics' },
- };
+ toEntity: { id: `topic-${n}`, name: `Topic ${n}` },
+ });
+ const tagRelation = { id: 'relation-tag', type: { id: TAG_PROPERTY_ID }, toEntity: { id: 'tag-1', name: 'Draft' } };
+
+ it('draws every topic, and only topics, on the Topics tab', () => {
+ mocks.sidePanel = { activeTabId: null, activeSystemTab: 'topics', setActiveSystemTab: vi.fn() };
+ mocks.entity = { ...claimEntity('Anything'), relations: [topic(1), topic(2), tagRelation] };
+ render();
+
+ expect(screen.getByTestId('topics-tab')).toBeInTheDocument();
+ expect(mocks.topicsTab?.topics).toEqual([topic(1), topic(2)]);
+ expect(mocks.topicsTab?.spaceId).toBe('space-1');
+ });
- it('draws them with the shared chip section, under the label Topics', () => {
- mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation] };
+ it('no longer draws them as chips anywhere on the page', () => {
+ mocks.sidePanel = { activeTabId: null, activeSystemTab: 'topics', setActiveSystemTab: vi.fn() };
+ mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] };
render();
- expect(screen.getByTestId('chip-section')).toHaveAttribute('data-label', 'Topics');
- expect(screen.getByTestId('activity').nextElementSibling).toBe(screen.getByTestId('chip-section'));
+ expect(screen.queryByTestId('chip-section')).toBeNull();
});
- it('hands the section the topic relations, scoped to the viewing space', () => {
- mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation] };
+ it('no longer repeats them on the Overview', () => {
+ mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] };
render();
- expect(mocks.chipSection?.relations).toEqual([topicRelation]);
- expect(mocks.chipSection?.spaceId).toBe('space-1');
+ expect(screen.queryByTestId('chip-section')).toBeNull();
+ expect(screen.queryByTestId('topics-tab')).toBeNull();
});
- // Tags share the header row with the type and are a different relation; only Topics moved.
- it('passes only topic relations, not the tags beside the type', () => {
- const tagRelation = { id: 'relation-2', type: { id: TAG_PROPERTY_ID }, toEntity: { id: 'tag-1', name: 'Draft' } };
- mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation, tagRelation] };
+ it('offers a Topics tab only when the claim has topics', () => {
+ mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] };
render();
- expect(mocks.chipSection?.relations).toEqual([topicRelation]);
+ expect((mocks.tabs?.systemTabsBefore as Array<{ label: string }>).map(tab => tab.label)).toContain('Topics');
});
});
diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx
index 8d73469d58..c6f113cb5c 100644
--- a/apps/web/core/claims/browse/claim-page-view.tsx
+++ b/apps/web/core/claims/browse/claim-page-view.tsx
@@ -2,12 +2,12 @@
import * as React from 'react';
+import cx from 'classnames';
import { usePathname } from 'next/navigation';
import { ClaimCommentPositionProvider } from '~/core/claims/browse/claim-comment-position';
import { ClaimPositionCommentControl } from '~/core/claims/browse/claim-position-comment';
import { TOPICS_PROPERTY_ID } from '~/core/claims/ontology';
-import { TAG_PROPERTY_ID } from '~/core/constants';
import type { DebateClaim } from '~/core/debates/api';
import { useBackfillReadinessForHeldPosition } from '~/core/debates/backfill-readiness-for-held-position';
import { useDebateClaims } from '~/core/debates/hooks';
@@ -22,6 +22,8 @@ import type { Relation, TabEntity } from '~/core/types';
import { NavUtils } from '~/core/utils/utils';
import { ClampedText } from '~/design-system/clamped-text';
+import { Fire } from '~/design-system/icons/fire';
+import { PrefetchLink as Link } from '~/design-system/prefetch-link';
import { Skeleton } from '~/design-system/skeleton';
import { Text } from '~/design-system/text';
@@ -33,8 +35,8 @@ import {
EntityPageInlineDescription,
} from '~/partials/entity-page/entity-page-inline-description';
import { EntityTabs } from '~/partials/entity-page/entity-tabs';
-import { META_CHIP_CLASS, RelationChipSection } from '~/partials/entity-page/relation-chip-section';
-import { SectionTitle } from '~/partials/entity-page/section-title';
+import { META_CHIP_CLASS } from '~/partials/entity-page/relation-chip-section';
+import { ClaimVerdictColumn } from '~/partials/explore/claim-explore-feed-card';
import { type ActivityKind, ProfileActivitySection } from '~/partials/profile/profile-activity-section';
import { SPACE_TABS_ANCHOR } from '~/partials/space-page/space-tabs-anchor';
@@ -42,16 +44,19 @@ import { ClaimEndSlot } from './claim-end-slot';
import { ClaimRecordTab } from './claim-record-tab';
import { getClaimSources } from './claim-sources';
import { ClaimSourcesTab } from './claim-sources-tab';
-import { ControversialTag } from './claim-summary';
-import { ClaimVerdict } from './claim-verdict';
+import { ClaimTopicsTab } from './claim-topics-tab';
import { useClaimRecord } from './use-claim-record';
import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state';
-type ClaimTab = 'overview' | 'debates' | 'claims' | 'sources' | 'custom';
+type ClaimTab = 'overview' | 'debates' | 'claims' | 'topics' | 'sources' | 'custom';
type ClaimSystemTab = Exclude;
-/** Shared with the cover/avatar header so its left edge stays aligned with the claim column. */
-export const CLAIM_PAGE_CONTENT_MAX_WIDTH = 720;
+/**
+ * Shared with the cover/avatar header so its left edge stays aligned with the claim column.
+ *
+ * 840 for 800px of content: the inset below sits inside this width, at 20px a side.
+ */
+export const CLAIM_PAGE_CONTENT_MAX_WIDTH = 840;
export const CLAIM_PAGE_CONTENT_INSET_CLASS = 'px-4 @[560px]:px-5';
export function resolveClaimTab({
@@ -69,6 +74,7 @@ export function resolveClaimTab({
if (panel.activeTabId) return 'custom';
if (panel.activeSystemTab === 'debates') return 'debates';
if (panel.activeSystemTab === 'claims') return 'claims';
+ if (panel.activeSystemTab === 'topics') return 'topics';
if (panel.activeSystemTab === 'sources') return 'sources';
return 'overview';
}
@@ -76,6 +82,7 @@ export function resolveClaimTab({
if (authoredTabId) return 'custom';
if (pathname.endsWith('/debates')) return 'debates';
if (pathname.endsWith('/claims')) return 'claims';
+ if (pathname.endsWith('/topics')) return 'topics';
if (pathname.endsWith('/sources')) return 'sources';
return 'overview';
}
@@ -128,13 +135,33 @@ export function ClaimPageView({
const row: DebateClaim | null = rowQuery.data?.claims.find(claim => claim.claim_entity_id === entityId) ?? null;
const state = useClaimResponseState({ claimId: entityId, spaceId, row, entity: entity ?? null });
const { responseKind, summary } = state;
+ // Explore's rule for drawing the verdict column at all: counts that answered, and at least one.
+ const hasVerdict = !summary.isLoading && summary.hasCounts && summary.total > 0;
+ // Whether the hero keeps a second track, which is deliberately not the same question.
+ //
+ // `hasVerdict` cannot be true until the counts answer, so a grid template derived from it alone
+ // painted every claim one-column and then re-wrapped the title the moment the column appeared —
+ // a layout shift at the very top of the page, on every load, that the old below-header verdict
+ // never had. An answered claim is the common case on this page, so the unknown state reserves
+ // the column and only a settled zero takes it away.
+ //
+ // `hasCounts`, not `isLoading`, is what "settled" means. A counts query that exhausts its
+ // retries leaves `total` at zero with nothing loading any more — the shape of an unanswered
+ // claim, which is exactly what it is not, and the distinction `hasCounts` exists to draw. Keying
+ // off `isLoading` gave the track back on that failure and re-wrapped the title anyway, and would
+ // have taken it away again if a later refetch succeeded. Unknown reserves; only a measured zero
+ // releases.
+ const reserveVerdictColumn = !summary.hasCounts || summary.total > 0;
const topics = React.useMemo(() => relationsOfType(entity?.relations, TOPICS_PROPERTY_ID), [entity?.relations]);
- const tags = React.useMemo(() => relationsOfType(entity?.relations, TAG_PROPERTY_ID), [entity?.relations]);
const topicIds = React.useMemo(() => topics.map(topic => topic.toEntity.id), [topics]);
const sources = React.useMemo(() => getClaimSources(entity?.relations ?? []), [entity?.relations]);
- // Named types only: an unnamed one would render as a raw id, which says less than no chip.
- const typeName = entity?.types.find(type => type.name)?.name ?? null;
+
+ // Whether anything is drawn above the claim. The hero pins its parts to explicit rows so the
+ // verdict can start on the title's, and row 1 belongs to this strip — so when nothing fills it,
+ // the rows have to move up rather than leave a `gap-y-4` above the claim that belongs to a row
+ // nothing occupies. Visible in the side panel and at phone widths, where that gap is set.
+ const hasChipsRow = (SHOW_HERO_TOPICS && topics.length > 0) || summary.isControversial;
const requestedTab = resolveClaimTab({
pathname,
@@ -151,6 +178,7 @@ export function ClaimPageView({
const hrefs = {
debates: `${overviewHref}/debates`,
claims: `${overviewHref}/claims`,
+ topics: `${overviewHref}/topics`,
sources: `${overviewHref}/sources`,
};
// Matches profile record tabs: unknown/error stays reachable, while a settled zero disappears.
@@ -160,11 +188,13 @@ export function ClaimPageView({
const hasClaims = hasRecordToShow(
record.claimsLoading || record.claimsError || record.claimsCountUnavailable ? undefined : record.claimsTotal
);
+ const hasTopics = topics.length > 0;
const hasSources = sources.length > 0;
const systemTabs = [
{ label: 'Overview', href: overviewHref, sidePanelKey: 'overview' },
...(hasDebates ? [{ label: 'Debates', href: hrefs.debates, sidePanelKey: 'debates' }] : []),
...(hasClaims ? [{ label: 'Related claims', href: hrefs.claims, sidePanelKey: 'claims' }] : []),
+ ...(hasTopics ? [{ label: 'Topics', href: hrefs.topics, sidePanelKey: 'topics' }] : []),
...(hasSources ? [{ label: 'Sources', href: hrefs.sources, sidePanelKey: 'sources' }] : []),
];
@@ -186,73 +216,117 @@ export function ClaimPageView({
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 }}
>
- {/* 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. */}
- {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
- phone — three widths of one layout, per the note above — break at different words.
- They give up the same three lines of vertical space, which a character count could
- not do; the same count spends a different number of lines at each width, which is the
- measurement the reader actually feels.
-
- `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. */}
- {isEditing ? (
-
+ {/* Hero: the Explore claim card's layout at page scale. Everything you can *do* to the
+ claim on the left — what it is, the claim, the pills — and where opinion stands on the
+ right, behind a rule that runs the full height. Under `claim-card-narrow` (a 520px
+ container) it stacks: the verdict drops below the pills and the rule goes, exactly as
+ the card does. `@container` here so that decision is the hero's width, not the page's.
+
+ No verdict, no column: a claim nobody has answered takes the full width, as on Explore. */}
+
+
+ {/* Above the claim and across both columns: what it is about. Capped, with the rest a
+ tab away rather than a wall of chips over the title. No type or tag chips — every
+ claim on this page is a Claim. Drawn only when there is something to draw, so a claim
+ with neither leaves no empty track behind. */}
+ {hasChipsRow ? (
+ sidePanelTab.setActiveSystemTab('topics') : undefined}
+ isControversial={summary.isControversial}
+ className="col-span-full row-start-1 mb-3 claim-card-narrow:mb-0"
+ />
+ ) : null}
+
+
+ {/* `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. */}
+ {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
+ phone break at different words but give up the same three lines of vertical space.
+ `ClampedText` measures an unclamped clone, so the toggle appears only when something
+ is genuinely hidden (GEO-2756). */}
+ {isEditing ? (
+
+ ) : (
+ entity.description && (
+
+ )
+ )}
+
+
+
+
+
+
+ {/* From the title's row down, so the share lines up with the claim's first line rather
+ than the chips above it; the rule runs beside the claim and the pills. Explore's own
+ column, not a copy of it. */}
+ {hasVerdict ? (
+
+
+
+ ) : null}
+
+
+
+
+ tab.label)}
+ divideBeforeAuthored
/>
- ) : (
- entity.description && (
-
- )
- )}
-
- {/* What this is. Topics — what it is *about* — used to sit opposite these, pushed to the
- right of the same row; they are their own section below now (GEO-2781), so this row
- has one job and no longer has to survive being squeezed from both ends in the side
- panel. */}
-
- {typeName && {typeName}}
- {tags.map(tag => (
- {tag.toEntity.name ?? tag.toEntity.id}
- ))}
- {/* Among the chips that say what this is, which is what "contested" is — and the same
- component the cards use, so all three surfaces move together. Not a chip itself:
- the flame and red are what make it findable among neutral ones. */}
- {summary.isControversial ? : null}
-
-
-
- tab.label)}
- divideBeforeAuthored
- />
;
topics: Relation[];
availableSpaceIds: string[];
@@ -331,6 +399,12 @@ function ClaimTabPanel({
);
}
+ if (activeTab === 'topics') {
+ // Every topic, uncapped, as explore cards: this tab is where the hero's "See all" leads, and a
+ // list somebody was sent to is a list worth ordering and describing rather than a row of chips.
+ return ;
+ }
+
if (activeTab === 'sources') {
return ;
}
@@ -364,17 +438,7 @@ function ClaimTabPanel({
return (
<>
-
- {/* The response control follows the aggregate result, so a reader understands the current
- split before being asked to add their own position. */}
-
-
-
- {/* The topic view's Subtopics, drawing a claim's Topics (GEO-2781) — same question for the
- reader, so the same shared section rather than two implementations. On a claim, Topics
- belong to the Overview's supporting context and follow its Activity record. */}
-
{/* Last, like the ordinary entity page. An empty thread is an invitation, not absence. */}
- {/* No readiness switch — the Debate toggle is gone from the product. Master left the header
- row that used to hold it; with nothing on its right there is no row, just a label. */}
- Your position
+ // No card of its own: it renders in the hero's left column, under the claim.
+
{control.responseError ? (
@@ -481,19 +549,86 @@ function ClaimPositionSection({
}
/**
- * The chip a space homepage uses for its types, reused here for the claim's type and its tags —
- * the same shape in both places, since they are the same kind of label.
+ * Whether the hero shows the claim's topics above it. Off for now: the Topics tab holds them, and the
+ * hero keeps only Controversial. Turning this back on restores the capped row and its "See all".
+ */
+const SHOW_HERO_TOPICS = false;
+
+/** How many topics the hero shows before "See all" hands over to the Topics tab. */
+const HERO_TOPICS_LIMIT = 5;
+
+/**
+ * The claim's topics above it, across the hero: the first few as links, and "See all" to the Topics
+ * tab when there are more. Controversial leads the row when it applies.
*
- * A plain span, and not a component wrapping the class: topics used to be drawn here too and
- * needed to be links with their own hover state, which is why {@link META_CHIP_CLASS} is a string
- * that callers compose rather than an element. Topics now come from `RelationChipSection`, which
- * composes it the same way.
+ * "See all" is a link to the tab's route on the page, and switches the panel's own tab in the side
+ * panel — the panel owns its navigation, so a link there would move the page behind it instead.
*/
-function MetaChip({ children }: { children: React.ReactNode }) {
+function ClaimTopicsRow({
+ topics,
+ spaceId,
+ seeAllHref,
+ onSeeAll,
+ isControversial,
+ className,
+}: {
+ topics: Relation[];
+ spaceId: string;
+ seeAllHref: string;
+ onSeeAll?: () => void;
+ isControversial: boolean;
+ className?: string;
+}) {
+ const seeAllClass = `${META_CHIP_CLASS} text-grey-04 transition-colors hover:border-text hover:text-text`;
+ const rowClass = cx('flex min-w-0 flex-wrap items-center gap-1.5', className);
+
+ const chips = (
+ <>
+ {/* First in the row: "contested" is a fact about the claim rather than one of its topics. The
+ chips' own shape and type — `META_CHIP_CLASS`, spelled out because its border and fill are
+ the parts that change — in the tag's red, so it reads as one of the row's labels and still
+ stands out from them. */}
+ {isControversial ? (
+
+
+ Controversial
+
+ ) : null}
+ {topics.slice(0, HERO_TOPICS_LIMIT).map(topic => (
+
+ {topic.toEntity.name ?? topic.toEntity.id}
+
+ ))}
+ {topics.length > HERO_TOPICS_LIMIT ? (
+ onSeeAll ? (
+
+ ) : (
+
+ See all
+
+ )
+ ) : null}
+ >
+ );
+
+ // A navigation landmark has to contain navigation. With `SHOW_HERO_TOPICS` off, the only thing
+ // that puts this row on the page is a claim being controversial — and that row holds one status
+ // chip and no links, so naming it "Topics" announced an empty region under a heading that
+ // describes something else entirely to anyone moving through the page by landmark.
+ if (topics.length === 0) {
+ return
{chips}
;
+ }
+
return (
-
- {children}
-
+
);
}
diff --git a/apps/web/core/claims/browse/claim-record-tab.test.tsx b/apps/web/core/claims/browse/claim-record-tab.test.tsx
index 54dcc0a6bb..57930d01ba 100644
--- a/apps/web/core/claims/browse/claim-record-tab.test.tsx
+++ b/apps/web/core/claims/browse/claim-record-tab.test.tsx
@@ -186,13 +186,7 @@ describe('ClaimRecordTab', () => {
);
view.rerender(
-
+
);
expect(mocks.hookCalls.at(-1)).toMatchObject({
@@ -203,6 +197,19 @@ describe('ClaimRecordTab', () => {
});
});
+ it('lets a debate fill the claim page column, on both tabs that can draw one', () => {
+ // The claim page's column is already the reading width, so a debate capped at the card's 560px
+ // sat narrower than everything around it. The flag has to reach the feed for that to happen —
+ // `DebateExploreFeedCard`'s own suite covers what it then does with it.
+ render();
+ expect(mocks.feedProps?.fullWidthDebates).toBe(true);
+
+ cleanup();
+
+ render();
+ expect(mocks.feedProps?.fullWidthDebates).toBe(true);
+ });
+
it('gives Debates the same sort order and only its Spaces filter', () => {
render();
diff --git a/apps/web/core/claims/browse/claim-record-tab.tsx b/apps/web/core/claims/browse/claim-record-tab.tsx
index 8fc500444e..92c1155948 100644
--- a/apps/web/core/claims/browse/claim-record-tab.tsx
+++ b/apps/web/core/claims/browse/claim-record-tab.tsx
@@ -39,13 +39,7 @@ type ClaimRecordTabProps = {
* difference is the initial space: a claim page opens scoped to the space in its URL, while
* clearing Spaces broadens the tab to every space carrying that same claim.
*/
-export function ClaimRecordTab({
- kind,
- claimId,
- spaceId,
- availableSpaceIds,
- sourceTopics,
-}: ClaimRecordTabProps) {
+export function ClaimRecordTab({ kind, claimId, spaceId, availableSpaceIds, sourceTopics }: ClaimRecordTabProps) {
return (
('best');
const spaces = useRecordSelection([spaceId]);
const topics = useRecordSelection();
@@ -196,6 +184,9 @@ function ClaimRecordTabContent({
}
errorLabel={isClaims ? 'Couldn’t load claims.' : 'Couldn’t load debates.'}
noun={isClaims ? 'claims' : 'debates'}
+ // The claim page's column is already the reading width; a capped debate sat narrower than
+ // everything else on the tab.
+ fullWidthDebates
/>
);
diff --git a/apps/web/core/claims/browse/claim-side-responders.tsx b/apps/web/core/claims/browse/claim-side-responders.tsx
index 2c0ba352b4..b54954a80e 100644
--- a/apps/web/core/claims/browse/claim-side-responders.tsx
+++ b/apps/web/core/claims/browse/claim-side-responders.tsx
@@ -44,7 +44,7 @@ export function ClaimSideResponders({
spaceId: string;
responseKind: ResponseKind;
direction: ActiveResponseDirection;
- /** Names the side in the claim's own vocabulary, for the panel's footer and the trigger's label. */
+ /** Names the side in the claim's own vocabulary, for the panel's title and the trigger's label. */
label: string;
/** The authoritative count for this side, which can exceed the faces the query returns. */
totalResponders: number;
@@ -115,7 +115,7 @@ export function ClaimSideResponders({
/**
* The list itself: the space editors and members popover pattern — same scroll cap, same divided
* rows, same counted footer — so a reader meets one list shape in the app rather than two that do
- * the same job differently.
+ * the same job differently — except that the count is a title above the rows, not a footer.
*
* Narrower than those, though. They hang off a page header with the width to spare; this hangs off a
* count inside a card, and at 356px it arrived as a slab wider than the column that opened it. A row
@@ -128,22 +128,34 @@ function ResponderList({ spaceIds, label, totalCount }: { spaceIds: string[]; la
staleTime: 30_000,
});
+ // People with a profile picture first, so the top of the list is faces rather than generated
+ // placeholders. Stable within each group, so the order the responders came in otherwise holds.
+ const sortedProfiles = React.useMemo(
+ () => (profiles ? [...profiles].sort((a, b) => Number(Boolean(b.avatarUrl)) - Number(Boolean(a.avatarUrl))) : []),
+ [profiles]
+ );
+
return (
+ {/* A title over the list rather than a counted footer under it: it says what the list is
+ before the reader scrolls it. The verb agrees with the count — "1 person agrees",
+ "6 people agree". */}
+
);
}
diff --git a/apps/web/core/claims/browse/claim-topics-tab.test.tsx b/apps/web/core/claims/browse/claim-topics-tab.test.tsx
new file mode 100644
index 0000000000..af8f2deb17
--- /dev/null
+++ b/apps/web/core/claims/browse/claim-topics-tab.test.tsx
@@ -0,0 +1,212 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup, render, screen } from '@testing-library/react';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ExploreFeedRow } from '~/core/explore/explore-card-item';
+import type { TopicConnectionCounts } from '~/core/topics/browse/use-topic-connection-counts';
+import type { Relation } from '~/core/types';
+import { normId } from '~/core/utils/norm-id';
+
+import { ClaimTopicsTab } from './claim-topics-tab';
+
+const mocks = vi.hoisted(() => ({
+ rows: [] as unknown[],
+ rowsLoading: false,
+ rowsError: false,
+ refetch: vi.fn(),
+ /** Ids the hydration was asked for, so the tab's dedupe and its order are both observable. */
+ rowIdCalls: [] as string[][],
+ counts: null as Record | null,
+ countsLoading: false,
+ countsError: false,
+ countIdCalls: [] as string[][],
+}));
+
+vi.mock('./use-claim-explore-rows', () => ({
+ useClaimExploreRows: (ids: string[]) => {
+ mocks.rowIdCalls.push(ids);
+ return {
+ data: mocks.rows,
+ isLoading: mocks.rowsLoading,
+ isError: mocks.rowsError,
+ isFetching: false,
+ refetch: mocks.refetch,
+ };
+ },
+}));
+
+vi.mock('~/core/topics/browse/use-topic-connection-counts', () => ({
+ useTopicConnectionCounts: (ids: string[]) => {
+ mocks.countIdCalls.push(ids);
+ return { countsByTopicId: mocks.counts, isLoading: mocks.countsLoading, isError: mocks.countsError };
+ },
+}));
+
+vi.mock('~/core/hooks/use-space-labels', () => ({
+ useSpaceLabels: () => ({ labelsById: {}, isLoading: false }),
+ spaceLabel: () => ({ name: 'Space One', image: null }),
+}));
+
+// The card has its own suite. Here it only has to say which topic it drew and with what counts.
+vi.mock('~/partials/explore/topic-explore-feed-card', () => ({
+ TopicExploreFeedCard: (props: { item: { entityId: string; title: string }; counts: unknown }) => (
+
+ {props.item.title}
+
+ ),
+}));
+
+const row = (id: string, title: string): ExploreFeedRow => ({
+ entityId: id,
+ spaceId: 'space-1',
+ types: [],
+ createdAtSec: 0,
+ title,
+ description: null,
+ imageUrl: null,
+ recordingUrls: [],
+ debateVideoUrls: [],
+ debateClaim: null,
+ commentCount: 0,
+ isMemberOrEditor: false,
+});
+
+const counts = (claims: number, news: number, debates: number): TopicConnectionCounts => ({
+ claims,
+ news,
+ debates,
+ total: claims + news + debates,
+});
+
+const topicRelation = (id: string) => ({ id: `relation-${id}`, toEntity: { id, name: id } }) as unknown as Relation;
+
+const drawnTopics = () => screen.getAllByTestId('topic-card').map(card => card.getAttribute('data-entity'));
+
+beforeEach(() => {
+ mocks.rows = [];
+ mocks.rowsLoading = false;
+ mocks.rowsError = false;
+ mocks.rowIdCalls = [];
+ mocks.counts = null;
+ mocks.countsLoading = false;
+ mocks.countsError = false;
+ mocks.countIdCalls = [];
+});
+
+afterEach(cleanup);
+
+describe('ClaimTopicsTab', () => {
+ const topics = [topicRelation('topic-a'), topicRelation('topic-b'), topicRelation('topic-c')];
+
+ it('orders by everything the metadata line adds up, largest first', () => {
+ mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B'), row('topic-c', 'C')];
+ mocks.counts = {
+ [normId('topic-a')]: counts(1, 0, 0),
+ [normId('topic-b')]: counts(100, 5, 2),
+ [normId('topic-c')]: counts(10, 0, 1),
+ };
+
+ render();
+
+ expect(drawnTopics()).toEqual(['topic-b', 'topic-c', 'topic-a']);
+ });
+
+ it('breaks a tie on claims, then on name, so the order is stable', () => {
+ mocks.rows = [row('topic-a', 'Zebra'), row('topic-b', 'Apple'), row('topic-c', 'Middle')];
+ mocks.counts = {
+ // Same total; different shapes.
+ [normId('topic-a')]: counts(5, 5, 0),
+ [normId('topic-b')]: counts(5, 5, 0),
+ [normId('topic-c')]: counts(9, 1, 0),
+ };
+
+ render();
+
+ expect(drawnTopics()).toEqual(['topic-c', 'topic-b', 'topic-a']);
+ });
+
+ it('hands each card its own counts', () => {
+ mocks.rows = [row('topic-a', 'A')];
+ mocks.counts = { [normId('topic-a')]: counts(7, 1, 2) };
+
+ render();
+
+ expect(screen.getByTestId('topic-card')).toHaveAttribute(
+ 'data-counts',
+ JSON.stringify({ claims: 7, news: 1, debates: 2, total: 10 })
+ );
+ });
+
+ it('keeps the claim’s order when only some count batches answered', () => {
+ // The counts arrive in batches, so one can fail while another succeeds. Sorting the partial
+ // map ranks every unmeasured topic as zero and then alphabetises it — neither the claim's
+ // order nor largest-first — and nothing on screen says which topics were measured.
+ mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B'), row('topic-c', 'C')];
+ mocks.countsError = true;
+ mocks.counts = { [normId('topic-c')]: counts(1, 0, 0) };
+
+ render();
+
+ expect(drawnTopics()).toEqual(['topic-a', 'topic-b', 'topic-c']);
+ });
+
+ it('still gives the cards whose batch answered their counts', () => {
+ mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B')];
+ mocks.countsError = true;
+ mocks.counts = { [normId('topic-b')]: counts(4, 1, 2) };
+
+ render();
+
+ const [first, second] = screen.getAllByTestId('topic-card');
+ expect(first).toHaveAttribute('data-counts', 'null');
+ expect(second).toHaveAttribute('data-counts', JSON.stringify({ claims: 4, news: 1, debates: 2, total: 7 }));
+ });
+
+ it('keeps the claim’s own order, and no counts, when the count could not be read', () => {
+ mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B'), row('topic-c', 'C')];
+ mocks.countsError = true;
+ mocks.counts = null;
+
+ render();
+
+ expect(drawnTopics()).toEqual(['topic-a', 'topic-b', 'topic-c']);
+ expect(screen.getAllByTestId('topic-card')[0]).toHaveAttribute('data-counts', 'null');
+ });
+
+ it('draws nothing until the counts are in, so the list cannot resequence under the reader', () => {
+ mocks.rows = [row('topic-a', 'A')];
+ mocks.countsLoading = true;
+
+ render();
+
+ expect(screen.queryByTestId('topic-card')).toBeNull();
+ expect(screen.getByText('Loading topics…')).toBeInTheDocument();
+ });
+
+ it('asks for a topic related twice only once', () => {
+ render(
+
+ );
+
+ expect(mocks.rowIdCalls.at(-1)).toEqual(['topic-a', 'topic-b']);
+ expect(mocks.countIdCalls.at(-1)).toEqual(['topic-a', 'topic-b']);
+ });
+
+ it('is honest about a claim with no topics, which is a route somebody bookmarked', () => {
+ render();
+
+ expect(screen.getByText('No topics have been linked to this claim yet.')).toBeInTheDocument();
+ });
+
+ it('offers a retry when the topics themselves could not be loaded', () => {
+ mocks.rowsError = true;
+
+ render();
+
+ expect(screen.getByText('Couldn’t load topics.')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/core/claims/browse/claim-topics-tab.tsx b/apps/web/core/claims/browse/claim-topics-tab.tsx
new file mode 100644
index 0000000000..b34fde8583
--- /dev/null
+++ b/apps/web/core/claims/browse/claim-topics-tab.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import * as React from 'react';
+
+import { useTopicConnectionCounts } from '~/core/topics/browse/use-topic-connection-counts';
+import type { Relation } from '~/core/types';
+import { normId } from '~/core/utils/norm-id';
+
+import { TopicExploreFeedCard } from '~/partials/explore/topic-explore-feed-card';
+import { PersonRecordFeed } from '~/partials/profile/person-record-feed';
+
+import { useClaimExploreRows } from './use-claim-explore-rows';
+
+/**
+ * The claim's topics, as an explore feed.
+ *
+ * It was a row of chips, which is the right answer for a header strip and the wrong one for a tab:
+ * a chip says a topic's name and nothing else, so a tab of them is a list with no way to tell which
+ * entry is worth opening. As cards they carry what the graph already knows about each topic — its
+ * description, its picture, and how much is attached to it — which is also what this list is
+ * ordered by.
+ *
+ * Ordered by the metadata it shows — debates + claims + news stories totalled, largest first. That
+ * is the ask's first choice and the one worth having, since "Best" over a handful of topics
+ * resolves to an order a reader cannot see. Ties break on claims, then on name, so the list is
+ * stable rather than merely sorted. (The card names those three in a different order than they are
+ * summed in; the sum is over all three either way.)
+ *
+ * Nothing renders until the counts are in. The rows and the counts are two requests, and painting
+ * the first would show the claim's own topic order and then resequence it under the reader — the
+ * same trade `useTopicLinkedEntities` makes, for the same reason.
+ *
+ * And nothing is ordered unless *every* count arrived. A partial answer falls back to the claim's
+ * order rather than ranking the topics it could not measure as zero.
+ *
+ * The feed itself is `PersonRecordFeed`, as the Sources tab does with the same shape of question —
+ * a bounded id list hydrated into explore cards. It draws a topic card instead of the shared one;
+ * everything around the rows is the same and is not worth a second copy.
+ */
+export function ClaimTopicsTab({ topics, spaceId }: { topics: Relation[]; spaceId: string }) {
+ // The claim's order, deduped: a topic related twice in different spaces is one card.
+ const topicIds = React.useMemo(() => {
+ const seen = new Set();
+ const ids: string[] = [];
+ for (const topic of topics) {
+ const key = normId(topic.toEntity.id);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ ids.push(topic.toEntity.id);
+ }
+ return ids;
+ }, [topics]);
+
+ const counts = useTopicConnectionCounts(topicIds);
+ // Hydrated in the claim's order and sorted afterwards. Sorting the ids first would work and would
+ // also change the query key the moment the counts landed, paying for the whole list twice.
+ const rows = useClaimExploreRows(topicIds, spaceId, topicIds.length > 0);
+
+ const countsByTopicId = counts.countsByTopicId;
+ const countsFailed = counts.isError;
+ const ordered = React.useMemo(() => {
+ // Ordering is a promise about *all* the numbers, so a partial answer cannot keep it. The
+ // counts arrive in batches, and one batch can fail while another succeeds — which leaves a map
+ // covering some topics and not others. Sorting on that ranks every unmeasured topic as zero
+ // and then alphabetises it: neither the claim's order nor largest-first, and nothing on screen
+ // says which. So any failure falls back to the claim's order, exactly as a total failure
+ // already did. The counts that did arrive still reach their own cards.
+ if (!countsByTopicId || countsFailed) return rows.data;
+
+ const totalOf = (entityId: string) => countsByTopicId[normId(entityId)];
+
+ return [...rows.data].sort(
+ (a, b) =>
+ (totalOf(b.entityId)?.total ?? 0) - (totalOf(a.entityId)?.total ?? 0) ||
+ (totalOf(b.entityId)?.claims ?? 0) - (totalOf(a.entityId)?.claims ?? 0) ||
+ a.title.localeCompare(b.title)
+ );
+ }, [countsByTopicId, countsFailed, rows.data]);
+
+ // The parent only offers this tab when the claim has topics. The route stays addressable
+ // directly, matching the Sources tab, so a stale bookmark still gets an honest empty state.
+ if (topicIds.length === 0) {
+ return
No topics have been linked to this claim yet.
;
+ }
+
+ return (
+ (
+
+ )}
+ />
+ );
+}
diff --git a/apps/web/core/claims/browse/claim-verdict.test.tsx b/apps/web/core/claims/browse/claim-verdict.test.tsx
deleted file mode 100644
index ed2a472cd4..0000000000
--- a/apps/web/core/claims/browse/claim-verdict.test.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import '@testing-library/jest-dom/vitest';
-import { cleanup, render, screen } from '@testing-library/react';
-
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import type { ClaimResponseSummary } from './claim-response-summary';
-import { summarizeClaimResponses } from './claim-response-summary';
-import { ClaimVerdict } from './claim-verdict';
-
-// The sides render their own responder query and popover, neither of which this module decides.
-vi.mock('./claim-summary', () => ({
- ClaimSides: () => ,
- ClaimSplitBar: () => ,
-}));
-
-const ENTITY = 'claim-1';
-const SPACE = 'space-1';
-
-/** The hook's shape, so a test says only what it is about. */
-function summary(overrides: Partial = {}): ClaimResponseSummary {
- return {
- ...summarizeClaimResponses(0, 0),
- isLoading: false,
- isViewerResponseLoading: false,
- hasCounts: true,
- viewerDirection: null,
- indexedViewerDirection: null,
- viewerSpaceId: null,
- ...overrides,
- };
-}
-
-function renderVerdict(value: ClaimResponseSummary) {
- return render();
-}
-
-afterEach(cleanup);
-
-describe('ClaimVerdict', () => {
- it('invites a first response on a claim the server says nobody has answered', () => {
- renderVerdict(summary());
-
- expect(screen.getByText('No responses yet')).toBeInTheDocument();
- });
-
- it('reports the share once there are responses', () => {
- renderVerdict(summary({ ...summarizeClaimResponses(17, 3) }));
-
- expect(screen.getByText('85%')).toBeInTheDocument();
- expect(screen.getByText('20 responses')).toBeInTheDocument();
- expect(screen.queryByText('No responses yet')).toBeNull();
- });
-
- // `total: 0` has three causes and only one of them is "nobody has answered". This module is the
- // one place that turns that zero into a statement about the claim, so it is the one place the
- // difference matters: a reader told "No responses yet" about a claim with two hundred of them has
- // been handed a wrong verdict rather than a missing one, with nothing on screen to say so.
- it('says nothing at all when the counts never answered', () => {
- // What a failed count query leaves behind: nothing loading any more, and no data.
- renderVerdict(summary({ hasCounts: false }));
-
- expect(screen.queryByText('No responses yet')).toBeNull();
- expect(screen.queryByTestId('claim-split-bar')).toBeNull();
- });
-
- it('says nothing while the summary is still being held back', () => {
- // The claim page reaches this on every load: the summary waits for the vocabulary, so until the
- // entity lands the hook has asked nothing and reports a zero that stands for nothing. Loading
- // is already false by then, so the skeleton above does not cover it.
- renderVerdict(summary({ hasCounts: false, isLoading: false }));
-
- expect(screen.queryByText('No responses yet')).toBeNull();
- });
-
- it('draws a skeleton while the counts are still on their way', () => {
- const { container } = renderVerdict(summary({ isLoading: true, hasCounts: false }));
-
- expect(screen.queryByText('No responses yet')).toBeNull();
- expect(container.querySelector('.animate-pulse')).not.toBeNull();
- });
-});
diff --git a/apps/web/core/claims/browse/claim-verdict.tsx b/apps/web/core/claims/browse/claim-verdict.tsx
deleted file mode 100644
index 3b75e3aec6..0000000000
--- a/apps/web/core/claims/browse/claim-verdict.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-'use client';
-
-import * as React from 'react';
-
-import { ENTITY_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response';
-
-import { Skeleton } from '~/design-system/skeleton';
-import { Text } from '~/design-system/text';
-
-import { type ClaimResponseSummary, claimSummaryTier } from './claim-response-summary';
-import { ClaimSides, ClaimSplitBar } from './claim-summary';
-
-/**
- * Where opinion sits on a claim: one number, the split, and who is on each side.
- *
- * Reads the same `claimSummaryTier` every card reads, so the page and a card describing the same
- * claim cannot say different things about it.
- *
- * At zero it invites a first response rather than rendering nothing at all, which is what it used
- * to do on the state most claims are in — but only at a zero the server actually reported. A zero
- * standing in for a failed or unasked question renders nothing, because the invitation is an
- * assertion about the claim and those two are not. From the first response the share is shown, however small
- * the sample: 93% of answered claims are unanimous and the median has two responses, so a "100%"
- * here is usually standing on very little — and what keeps that honest is the responder counts
- * directly beneath it, not withholding the number.
- */
-export function ClaimVerdict({
- entityId,
- spaceId,
- responseKind,
- summary,
-}: {
- entityId: string;
- spaceId: string;
- responseKind: ResponseKind;
- summary: ClaimResponseSummary;
-}) {
- if (summary.isLoading) {
- return ;
- }
-
- // Nothing, where the counts never answered.
- //
- // `total: 0` is also what a failed count query and a held-back hook produce, and this module is
- // the one place that turns a zero into a *claim about the world* — "No responses yet", followed
- // by an invitation to be the first. Said over a claim with two hundred responses that is not a
- // missing verdict but a wrong one, and the reader has no way to tell. On the claim page the
- // held-back case is reached on every load: the summary waits for the vocabulary, so until the
- // entity lands there is a window where nothing is loading and nothing has been asked.
- //
- // Rendering nothing is what this did before it learned to invite, and it is the honest answer to
- // a question that was never put.
- if (!summary.hasCounts) return null;
-
- const copy = ENTITY_RESPONSE_COPY[responseKind];
- const tier = claimSummaryTier(summary.total);
-
- // An invitation, where before there was nothing at all — and nothing is what the great majority
- // of claims render, so this is the state most readers meet.
- if (tier === 'invite') {
- return (
-
-
- No responses yet
-
-
- {copy.firstResponsePrompt}
-
-
- );
- }
-
- const percent = summary.percent ?? 0;
-
- return (
-
-
- {/* The share and what it is a share *of*, on one baseline. They were stacked, which gave the
- verb a line of its own for one small word and pushed everything under it down. Sharing a
- line reads as one statement — "68% agree" — which is what it is, and it matches the
- explore card exactly. */}
-
- {percent}%
-
- {/* "Agreements" → "agree", "Verifications" → "verify" reads wrong; use the action verb. */}
- {copy.positiveAction.toLowerCase()}
-
-
- {/* No Controversial tag here: it moved up to the hero chips, beside the type and tags, where
- it says what kind of claim this is. Flagging it in both places said it twice. */}
-
- {summary.total} {summary.total === 1 ? 'response' : 'responses'}
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/core/topics/browse/use-topic-connection-counts.test.ts b/apps/web/core/topics/browse/use-topic-connection-counts.test.ts
new file mode 100644
index 0000000000..db67e5aa9d
--- /dev/null
+++ b/apps/web/core/topics/browse/use-topic-connection-counts.test.ts
@@ -0,0 +1,112 @@
+import { print } from 'graphql';
+import { describe, expect, it } from 'vitest';
+
+import { normId } from '~/core/utils/norm-id';
+
+import {
+ TOPIC_COUNT_BATCH_SIZE,
+ decodeTopicConnectionCounts,
+ topicConnectionCountsDocument,
+ topicCountBatches,
+} from './use-topic-connection-counts';
+
+describe('topicConnectionCountsDocument', () => {
+ it('declares one variable and three buckets per topic', () => {
+ const printed = print(topicConnectionCountsDocument(3));
+
+ for (const index of [0, 1, 2]) {
+ expect(printed).toContain(`$topic${index}: UUID!`);
+ expect(printed).toContain(`claims${index}:`);
+ expect(printed).toContain(`news${index}:`);
+ expect(printed).toContain(`debates${index}:`);
+ }
+ expect(printed).not.toContain('$topic3');
+ });
+
+ it('reuses the parsed document for a given topic count', () => {
+ expect(topicConnectionCountsDocument(2)).toBe(topicConnectionCountsDocument(2));
+ expect(topicConnectionCountsDocument(2)).not.toBe(topicConnectionCountsDocument(4));
+ });
+
+ it('counts entities, not relations', () => {
+ // The distinction the topic page's composition strip gets wrong by design: a claim carrying
+ // `Topics` in two spaces is two relations and one claim, and this list is *ordered* by the
+ // number, so it has to be a count of things.
+ const printed = print(topicConnectionCountsDocument(1));
+
+ expect(printed).toContain('entitiesConnection');
+ expect(printed).not.toContain('relationsConnection');
+ });
+
+ it('reaches debates through the claims that name the topic, not through the topic', () => {
+ const printed = print(topicConnectionCountsDocument(1)).replace(/\s+/g, ' ');
+
+ expect(printed).toContain(
+ 'debates0: entitiesConnection( filter: {typeIds: {overlaps: $debateTypeIds}, relations: {some: {typeId: {is: $debateClaimsPropertyId}, toEntity: {relations: {some: {typeId: {is: $topicsPropertyId}, toEntityId: {is: $topic0}}}}}}} )'
+ );
+
+ // And a claim reaches its topic in one hop, so the two buckets cannot silently become the same
+ // query.
+ expect(printed).toContain(
+ 'claims0: entitiesConnection( filter: {typeIds: {overlaps: $claimTypeIds}, relations: {some: {typeId: {is: $topicsPropertyId}, toEntityId: {is: $topic0}}}} )'
+ );
+ });
+});
+
+describe('decodeTopicConnectionCounts', () => {
+ const ids = ['AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'];
+
+ it('puts each aliased bucket back on the id that asked for it, and totals them', () => {
+ const decoded = decodeTopicConnectionCounts(
+ {
+ claims0: { totalCount: 117 },
+ news0: { totalCount: 2 },
+ debates0: { totalCount: 3 },
+ claims1: { totalCount: 5 },
+ news1: { totalCount: 0 },
+ debates1: { totalCount: 0 },
+ },
+ ids
+ );
+
+ expect(decoded[normId(ids[0])]).toEqual({ claims: 117, news: 2, debates: 3, total: 122 });
+ expect(decoded[normId(ids[1])]).toEqual({ claims: 5, news: 0, debates: 0, total: 5 });
+ });
+
+ it('reads a missing bucket as zero rather than as undefined', () => {
+ const decoded = decodeTopicConnectionCounts({ claims0: { totalCount: 4 } }, [ids[0]]);
+
+ expect(decoded[normId(ids[0])]).toEqual({ claims: 4, news: 0, debates: 0, total: 4 });
+ });
+});
+
+describe('topicCountBatches', () => {
+ // Dash-free: `normId` strips dashes, so an id written with them would not come back as it went in.
+ const id = (n: number) => `topic${String(n).padStart(3, '0')}`;
+
+ it('keeps a claim-sized list to one request', () => {
+ // Claims on testnet carry at most 7 topics, so this is the case that actually happens.
+ expect(topicCountBatches([id(3), id(1), id(2)])).toEqual([[id(1), id(2), id(3)]]);
+ });
+
+ it('bounds the request rather than growing it without limit', () => {
+ const ids = Array.from({ length: TOPIC_COUNT_BATCH_SIZE * 2 + 1 }, (_, index) => id(index));
+
+ expect(topicCountBatches(ids).map(batch => batch.length)).toEqual([
+ TOPIC_COUNT_BATCH_SIZE,
+ TOPIC_COUNT_BATCH_SIZE,
+ 1,
+ ]);
+ expect(topicCountBatches(ids).flat()).toEqual(ids);
+ });
+
+ it('asks once for a topic named twice, however it was spelled', () => {
+ expect(topicCountBatches(['AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'])).toEqual([
+ ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
+ ]);
+ });
+
+ it('asks for nothing when there are no topics', () => {
+ expect(topicCountBatches([])).toEqual([]);
+ });
+});
diff --git a/apps/web/core/topics/browse/use-topic-connection-counts.ts b/apps/web/core/topics/browse/use-topic-connection-counts.ts
new file mode 100644
index 0000000000..f511e74d8c
--- /dev/null
+++ b/apps/web/core/topics/browse/use-topic-connection-counts.ts
@@ -0,0 +1,213 @@
+'use client';
+
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { useQueries } from '@tanstack/react-query';
+
+import * as React from 'react';
+
+import { Effect } from 'effect';
+import { parse } from 'graphql';
+
+import { CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology';
+import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID } from '~/core/debates/ontology';
+import { ID } from '~/core/id';
+import { graphql } from '~/core/io/graphql-client';
+import { normId } from '~/core/utils/norm-id';
+
+import { NEWS_STORY_TYPE_ID } from '../ontology';
+
+/** How much is attached to a topic, as a card reports it. */
+export type TopicConnectionCounts = {
+ claims: number;
+ news: number;
+ /** Debates on the topic's claims — two hops, not one. See the query. */
+ debates: number;
+ /** What the list is ordered by: everything the card names, added up. */
+ total: number;
+};
+
+export const EMPTY_TOPIC_CONNECTION_COUNTS: TopicConnectionCounts = { claims: 0, news: 0, debates: 0, total: 0 };
+
+/**
+ * Asked of entities rather than of relations, for all three buckets.
+ *
+ * `relationsConnection` answers faster and is what the topic page's composition strip uses, but it
+ * counts *links*: the same claim carrying `Topics` in two spaces is two relations, and on
+ * `AI governance` that is 552 relations against 505 claims and 209 against 195 news stories. A card
+ * saying "552 claims" beside a topic page saying 505 is a number nobody can reconcile, and the
+ * count is also what orders the list — so it has to be a count of things.
+ *
+ * Debates are the bucket that was never a `Topics` relation at all. A Debate carries `Claims` and
+ * never `Topics`, so its link to a topic is two hops — debate to claim, claim to topic — expressed
+ * as a nested filter on the far end of the relation. That is also exactly what the ask describes:
+ * debates connected to the *attached claims*, not to the topic.
+ *
+ * Unscoped to spaces, unlike the composition strip. A topic gathers across the whole graph, and
+ * this count is asked on a claim page that is scoped to one space — narrowing to the spaces a
+ * viewer happens to be in would make a topic's size depend on who is reading it, and would reorder
+ * the tab under anyone whose space allowlist resolved a beat after the counts did.
+ *
+ * One request per batch of topics, and in practice one batch: each topic contributes three aliased
+ * counts over the same connections, and the list is split at {@link TOPIC_COUNT_BATCH_SIZE}. Claims
+ * on testnet carry at most 7 topics, so a claim's whole Topics tab is normally a single request.
+ * Five topics measured ~0.75s against testnet, against five round trips for the same work.
+ */
+function buildCountsSource(topicCount: number): string {
+ const declarations = [
+ '$topicsPropertyId: UUID!',
+ '$claimTypeIds: [UUID!]',
+ '$newsTypeIds: [UUID!]',
+ '$debateTypeIds: [UUID!]',
+ '$debateClaimsPropertyId: UUID!',
+ ...Array.from({ length: topicCount }, (_, index) => `$topic${index}: UUID!`),
+ ].join(', ');
+
+ const buckets = Array.from({ length: topicCount }, (_, index) => {
+ const topic = `$topic${index}`;
+
+ const namesTopic = `relations: { some: { typeId: { is: $topicsPropertyId }, toEntityId: { is: ${topic} } } }`;
+
+ return /* GraphQL */ `
+ claims${index}: entitiesConnection(filter: { typeIds: { overlaps: $claimTypeIds }, ${namesTopic} }) {
+ totalCount
+ }
+ news${index}: entitiesConnection(filter: { typeIds: { overlaps: $newsTypeIds }, ${namesTopic} }) {
+ totalCount
+ }
+ debates${index}: entitiesConnection(
+ filter: {
+ typeIds: { overlaps: $debateTypeIds }
+ relations: { some: { typeId: { is: $debateClaimsPropertyId }, toEntity: { ${namesTopic} } } }
+ }
+ ) {
+ totalCount
+ }
+ `;
+ }).join('\n');
+
+ return /* GraphQL */ `query TopicConnectionCounts(${declarations}) {\n${buckets}\n}`;
+}
+
+/**
+ * Parsed documents by topic count.
+ *
+ * The document is shaped by *how many* topics are being counted and by nothing else — the ids
+ * themselves are variables — so a claim page with three topics reuses the same parsed document as
+ * every other three-topic claim, rather than re-parsing a string per render.
+ */
+const documentsByTopicCount = new Map>();
+
+export function topicConnectionCountsDocument(topicCount: number): TypedDocumentNode {
+ const cached = documentsByTopicCount.get(topicCount);
+ if (cached) return cached;
+
+ const document = parse(buildCountsSource(topicCount)) as TypedDocumentNode;
+ documentsByTopicCount.set(topicCount, document);
+ return document;
+}
+
+type CountsResponse = Record;
+
+/** Decodes the aliased buckets back onto the ids that produced them, keyed by normalized id. */
+export function decodeTopicConnectionCounts(
+ response: CountsResponse,
+ topicIds: readonly string[]
+): Record {
+ const out: Record = {};
+
+ topicIds.forEach((id, index) => {
+ const claims = response[`claims${index}`]?.totalCount ?? 0;
+ const news = response[`news${index}`]?.totalCount ?? 0;
+ const debates = response[`debates${index}`]?.totalCount ?? 0;
+ out[normId(id)] = { claims, news, debates, total: claims + news + debates };
+ });
+
+ return out;
+}
+
+/**
+ * Topics per request.
+ *
+ * Each one contributes three aliased counts, one of them a two-hop nested filter, so the request
+ * grows with the list. Claims measured on testnet carry at most 7 topics and three at the 95th
+ * percentile, which means this is one request in practice — the chunking is what stops a
+ * topic-heavy claim from becoming one unbounded query, and what keeps a chunk that fails from
+ * costing every other topic its numbers.
+ *
+ * The same shape `useClaimExploreRows` and `fetchExploreRowsByIds` use, for the same reason.
+ */
+export const TOPIC_COUNT_BATCH_SIZE = 10;
+
+/**
+ * Topic ids as request-sized batches: deduped, normalized, and sorted.
+ *
+ * Sorted so the same set of topics asked in a different order is the same cache entry rather than
+ * a second request — which matters here, because the tab re-asks in count order once the counts
+ * have landed.
+ */
+export function topicCountBatches(topicIds: readonly string[]): string[][] {
+ const ids = [...new Set(topicIds.map(normId))].sort();
+ const batches: string[][] = [];
+ for (let start = 0; start < ids.length; start += TOPIC_COUNT_BATCH_SIZE) {
+ batches.push(ids.slice(start, start + TOPIC_COUNT_BATCH_SIZE));
+ }
+ return batches;
+}
+
+type CountsQueryResult = { data?: Record; isLoading: boolean; isError: boolean };
+
+function combineTopicConnectionCounts(queries: CountsQueryResult[]) {
+ // `null`, not `{}`, while nothing has answered: an empty map is indistinguishable from a topic
+ // the counts came back empty for, and the card draws no metadata line for either.
+ const answered = queries.filter(query => query.data);
+
+ return {
+ countsByTopicId: answered.length > 0 ? Object.assign({}, ...answered.map(query => query.data)) : null,
+ isLoading: queries.some(query => query.isLoading),
+ /**
+ * A failed count is not an empty one. The tab still lists its topics, in the order the claim
+ * carries them in, rather than printing zeros for numbers nobody measured.
+ *
+ * True of a *partial* failure too, which is why this is `some` rather than `every`: with more
+ * than one batch, one can fail while another answers, and half the numbers cannot order the
+ * list. The cards whose batch did answer still get their own counts; the rest draw no metadata
+ * line.
+ */
+ isError: queries.some(query => query.isError),
+ };
+}
+
+/**
+ * Claims, news stories and debates attached to each of these topics.
+ *
+ * Returns a map rather than an array so a caller can look a topic up without depending on the
+ * order it asked in — which is the point, since the order it renders in is derived from these
+ * numbers.
+ */
+export function useTopicConnectionCounts(topicIds: string[]) {
+ const batches = React.useMemo(() => topicCountBatches(topicIds), [topicIds]);
+
+ return useQueries({
+ queries: batches.map(ids => ({
+ queryKey: ['topic', 'connection-counts', ids],
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ Effect.runPromise(
+ graphql({
+ query: topicConnectionCountsDocument(ids.length),
+ decoder: (response: CountsResponse) => decodeTopicConnectionCounts(response, ids),
+ variables: {
+ topicsPropertyId: ID.uuidToHex(TOPICS_PROPERTY_ID),
+ claimTypeIds: [ID.uuidToHex(CLAIM_TYPE_ID)],
+ newsTypeIds: [ID.uuidToHex(NEWS_STORY_TYPE_ID)],
+ debateTypeIds: [ID.uuidToHex(DEBATE_TYPE_ID)],
+ debateClaimsPropertyId: ID.uuidToHex(DEBATE_CLAIMS_PROPERTY_ID),
+ ...Object.fromEntries(ids.map((id, index) => [`topic${index}`, ID.uuidToHex(id)])),
+ },
+ signal,
+ })
+ ),
+ staleTime: 30_000,
+ })),
+ combine: combineTopicConnectionCounts,
+ });
+}
diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx
index 97df8f9247..270f7539df 100644
--- a/apps/web/partials/explore/claim-explore-feed-card.tsx
+++ b/apps/web/partials/explore/claim-explore-feed-card.tsx
@@ -372,8 +372,12 @@ export function ClaimExploreFeedCard({
* `ClaimSummary`'s responder cluster read the *same* `entityRespondersQueryKey`, so react-query
* serves both from one cache entry and one request. That is worth knowing before either side is
* repointed at a query of its own.
+ *
+ * Exported for the claim page's hero, which is this card's layout at page scale: the same column,
+ * so the page and the feed cannot describe one claim in two shapes. The caller supplies the
+ * `@container` the `claim-card-narrow` rules measure.
*/
-function ClaimVerdictColumn({
+export function ClaimVerdictColumn({
entityId,
spaceId,
responseKind,
diff --git a/apps/web/partials/explore/debate-explore-feed-card.test.tsx b/apps/web/partials/explore/debate-explore-feed-card.test.tsx
index bf49b49a14..95d3525725 100644
--- a/apps/web/partials/explore/debate-explore-feed-card.test.tsx
+++ b/apps/web/partials/explore/debate-explore-feed-card.test.tsx
@@ -411,16 +411,10 @@ describe('DebateExploreFeedCard', () => {
expect(onPlaybackAvailabilityChange).not.toHaveBeenCalled();
intersectAll(0.1);
- expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith(
- 'fd51f935-2063-4617-8039-7b672b23364c',
- true
- );
+ expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', true);
intersectAll(0);
- expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith(
- 'fd51f935-2063-4617-8039-7b672b23364c',
- false
- );
+ expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', false);
});
it('unregisters stale playable data when a refetch replaces the player with fallback', () => {
@@ -429,20 +423,14 @@ describe('DebateExploreFeedCard', () => {
const onPlaybackAvailabilityChange = vi.fn();
const view = renderCard({ onPlaybackAvailabilityChange });
intersectAll(0.7);
- expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith(
- 'fd51f935-2063-4617-8039-7b672b23364c',
- true
- );
+ expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', true);
// TanStack Query retains the previous data when a background refetch fails.
mocks.debateQuery = { data: watchableDebate(), isError: true };
view.rerenderCard();
expect(screen.getByTestId('fallback')).toBeInTheDocument();
- expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith(
- 'fd51f935-2063-4617-8039-7b672b23364c',
- false
- );
+ expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', false);
});
it('does not request playback from a loading skeleton that may resolve to the fallback', () => {
@@ -587,6 +575,33 @@ describe('DebateExploreFeedCard', () => {
* itself — the open question in that ticket's notes. This control is the answer, and it has to
* stay a real link: the Debate entity's page *is* the full-screen feed anchored to that debate.
*/
+ describe('column width', () => {
+ /** The one element carrying the column budget: the card's single inner column. */
+ const column = (container: HTMLElement) => container.querySelector('article > div') as HTMLElement;
+
+ it('caps itself at the card width, fitted to the viewport, by default', () => {
+ const { container } = renderCard();
+
+ expect(column(container).className).toContain('max-w-[var(--debate-card-column-width)]');
+ expect(column(container).style.getPropertyValue('--debate-card-column-width')).toBe(
+ 'clamp(320px, calc(83dvh - 178px), 560px)'
+ );
+ });
+
+ it('raises the ceiling to the container under fullWidth without dropping the viewport budget', () => {
+ // `fullWidth` means "this column is already the reading width, don't cap me at 560px". It
+ // used to drop the `max-w` and the budget together, which on the claim page's 840px column
+ // made the card ~1140px tall — so on a 900px viewport the second debater and the interaction
+ // bar could not be seen together, which is the one thing the budget guarantees.
+ const { container } = renderCard({ fullWidth: true });
+
+ expect(column(container).className).toContain('max-w-[var(--debate-card-column-width)]');
+ expect(column(container).style.getPropertyValue('--debate-card-column-width')).toBe(
+ 'max(320px, min(calc(83dvh - 178px), 100%))'
+ );
+ });
+ });
+
describe('full-screen control', () => {
it('links to the Debate entity, which is the anchored full-screen feed', () => {
renderCard();
diff --git a/apps/web/partials/explore/debate-explore-feed-card.tsx b/apps/web/partials/explore/debate-explore-feed-card.tsx
index 5a3c478827..ab352860eb 100644
--- a/apps/web/partials/explore/debate-explore-feed-card.tsx
+++ b/apps/web/partials/explore/debate-explore-feed-card.tsx
@@ -65,6 +65,22 @@ const DEBATE_CARD_COLUMN_STYLE = {
'--debate-card-column-width': 'clamp(320px, calc(83dvh - 178px), 560px)',
} as React.CSSProperties;
+/**
+ * The same budget with the column, rather than 560px, as its ceiling.
+ *
+ * `fullWidth` means "this column is already the reading width, don't cap it at the card's 560px" —
+ * it does not mean "stop fitting the viewport". Dropping the budget outright made a debate on the
+ * claim page's 840px column about 1140px tall (two `aspect-480/289` tiles at ~800px), so on a
+ * 900px viewport the second debater and the interaction bar could not be seen together — which is
+ * the single thing the budget above exists to guarantee.
+ *
+ * `min` with `100%` rather than a larger constant: the ceiling is whatever column the card was
+ * given, so this is right at 840px and at any other width a host passes.
+ */
+const DEBATE_CARD_FULL_WIDTH_STYLE = {
+ '--debate-card-column-width': 'max(320px, min(calc(83dvh - 178px), 100%))',
+} as React.CSSProperties;
+
type DebateExploreFeedCardProps = {
item: ExploreFeedItem;
/** Hide the space thumbnail + space-name link in the meta row (same semantics as ExploreFeedCard). */
@@ -75,6 +91,15 @@ type DebateExploreFeedCardProps = {
titleOpensSidePanel?: boolean;
/** Compact title and metadata treatment used by the narrow profile Activity rail. */
compactChrome?: boolean;
+ /**
+ * Raise the column's ceiling from the card's 560px to the container's own width. For a surface
+ * whose column is already the reading width, where the capped card sat narrower than everything
+ * around it — the claim page's Debates tab.
+ *
+ * Not "uncapped": the viewport-height budget still applies, so the whole card still fits the
+ * screen it is watched on. See {@link DEBATE_CARD_FULL_WIDTH_STYLE}.
+ */
+ fullWidth?: boolean;
/**
* Called when a coordinated surface's player is clicked. An allowed active player transfers
* before the click reaches it. A visible non-owner consumes that first click while ownership
@@ -108,6 +133,7 @@ export function DebateExploreFeedCard({
hideJoinButton = false,
titleOpensSidePanel = false,
compactChrome = false,
+ fullWidth = false,
onPlaybackRequest,
onPlaybackAvailabilityChange,
fallback,
@@ -302,7 +328,7 @@ export function DebateExploreFeedCard({
and what keeps the bar beneath the videos the same width as them. */}
{/* The way out of the card and into the debate at full size. This corner used to hold
"View all", a link to the space's whole debates list. Since GEO-2879 headed the card
diff --git a/apps/web/partials/explore/explore-card-chrome.tsx b/apps/web/partials/explore/explore-card-chrome.tsx
new file mode 100644
index 0000000000..d989169bd2
--- /dev/null
+++ b/apps/web/partials/explore/explore-card-chrome.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import * as React from 'react';
+
+import type { ExploreFeedItem } from '~/core/explore/explore-card-item';
+import { NavUtils } from '~/core/utils/utils';
+
+import { FallbackImage } from '~/design-system/fallback-image';
+import { PrefetchLink as Link } from '~/design-system/prefetch-link';
+
+import { EntityRowActions } from '~/partials/entity-page/entity-row-actions';
+
+import { ExploreCardTitle } from './explore-card-title';
+import { ExploreFeedCommentLink } from './explore-feed-comment-link';
+
+/**
+ * The parts of an explore card that every card type shares, as components rather than as a class
+ * list to copy.
+ *
+ * Its own module for the reason {@link ExploreCardTitle} and {@link MetaDot} are: a second card
+ * type needs the same chrome, and reaching into `explore-feed-card` for it would close a cycle the
+ * moment that dispatcher ever draws the second type. Copying it instead is what this file exists
+ * to prevent — the two existing copies of the meta row drifted on spacing and font weight without
+ * either diff showing it.
+ */
+
+/** The card's own frame: its padding and the rule under it, cleared on the last card in a feed. */
+export const EXPLORE_CARD_CLASS = 'flex flex-col gap-2 border-b border-divider py-4 last:border-b-0';
+
+/** The vote and comment row every card body ends on. */
+export function ExploreCardActions({ item }: { item: ExploreFeedItem }) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The default body: thumbnail on the left, title and description beside it.
+ *
+ * The thumbnail navigates even where the title opens the side panel — a picture is not a heading,
+ * and the panel is reached by the name.
+ */
+export function ExploreCardDefaultBody({
+ item,
+ actions,
+ titleOpensSidePanel,
+ compactTitle = false,
+ meta,
+}: {
+ item: ExploreFeedItem;
+ /** The vote / comment row, owned by the card so bodies render it identically. */
+ actions: React.ReactNode;
+ /** Threaded to the title only. The thumbnail beside it still navigates. */
+ titleOpensSidePanel: boolean;
+ compactTitle?: boolean;
+ /**
+ * A line of the card's own metadata, between the description and the actions.
+ *
+ * Only the topic card passes one — how much is attached to a topic is the thing that
+ * distinguishes one from the next, and nothing else the generic body draws can say it. A slot
+ * rather than a second copy of this body: the copy is what drifts.
+ */
+ meta?: React.ReactNode;
+}) {
+ return (
+
+ {item.imageUrl ? (
+
+
+
+ ) : null}
+
+
+
+ {item.description ? (
+
+ {item.description}
+
+ ) : null}
+
+
+ {meta}
+ {actions}
+
+
+ );
+}
diff --git a/apps/web/partials/explore/explore-feed-card.tsx b/apps/web/partials/explore/explore-feed-card.tsx
index 672ca33133..f6317611df 100644
--- a/apps/web/partials/explore/explore-feed-card.tsx
+++ b/apps/web/partials/explore/explore-feed-card.tsx
@@ -9,19 +9,14 @@ import { isDebateEntity } from '~/core/debates/is-debate-entity';
import type { ExploreFeedItem } from '~/core/explore/fetch-explore-feed';
import { RANKING_BLOCK_TYPE_ID } from '~/core/ranking-block-ids';
import { normId } from '~/core/utils/norm-id';
-import { NavUtils } from '~/core/utils/utils';
-
-import { FallbackImage } from '~/design-system/fallback-image';
-import { PrefetchLink as Link } from '~/design-system/prefetch-link';
import { PublishedRecordingPlayer } from '~/partials/community-calls/published-recording-player';
-import { EntityRowActions } from '~/partials/entity-page/entity-row-actions';
import { type ClaimCardVariant, ClaimExploreFeedCard } from './claim-explore-feed-card';
import { DebateExploreFeedCard } from './debate-explore-feed-card';
import { DebateExploreMetaRow } from './debate-explore-meta-row';
+import { EXPLORE_CARD_CLASS, ExploreCardActions, ExploreCardDefaultBody } from './explore-card-chrome';
import { ExploreCardTitle } from './explore-card-title';
-import { ExploreCommentsIcon } from './explore-comments-icon';
import { ExploreMetaRow } from './explore-meta-row';
import { RankingCardBody } from './explore-ranking-card-body';
@@ -42,21 +37,14 @@ type ExploreFeedCardProps = {
responseNote?: (responseKind: 'stance' | 'veracity', position: boolean) => React.ReactNode;
/** Compact title and metadata treatment for a debate in profile Activity. */
compactDebateChrome?: boolean;
+ /** Let a debate fill the column rather than its viewport-fitted cap. See `DebateExploreFeedCard`. */
+ fullWidthDebate?: boolean;
/** Transfer playback ownership when this debate's player is clicked. Ignored by other row types. */
onDebatePlaybackRequest?: (debateId: string) => void;
/** Register whether this debate currently has a mounted player. Ignored by other row types. */
onDebatePlaybackAvailabilityChange?: (debateId: string, available: boolean) => void;
};
-function ExploreFeedCommentLink({ href, count }: { href: string; count: number }) {
- return (
-
-
- {count}
-
- );
-}
-
const COMMUNITY_CALL_EVENT_TYPE = normId(EVENT_SCHEMA.COMMUNITY_CALL_EVENT_TYPE);
const CLAIM_TYPE = normId(CLAIM_TYPE_ID);
const RANKING_BLOCK_TYPE = normId(RANKING_BLOCK_TYPE_ID);
@@ -65,48 +53,10 @@ type CardBodyProps = {
item: ExploreFeedItem;
/** The vote / comment row, owned by the shell so bodies render it identically. Not every body takes it. */
actions: React.ReactNode;
- /** Threaded to the title only. The thumbnail beside it still navigates — see `BaseExploreFeedCard`. */
+ /** Threaded to the title only. The thumbnail beside it still navigates — see `ExploreCardDefaultBody`. */
titleOpensSidePanel: boolean;
};
-/** The default body: thumbnail on the left, title and description beside it. */
-function DefaultCardBody({
- item,
- actions,
- titleOpensSidePanel,
- compactTitle = false,
-}: CardBodyProps & { compactTitle?: boolean }) {
- return (
-
- {item.imageUrl ? (
-
-
-
- ) : null}
-
-
-
- {item.description ? (
-
- {item.description}
-
- ) : null}
-
-
- {actions}
-
-
- );
-}
-
/** A Community call event's body */
function CommunityCallCardBody({ item, actions, titleOpensSidePanel }: CardBodyProps) {
const sources = useRecordingSources({
@@ -149,6 +99,7 @@ export function ExploreFeedCard(props: ExploreFeedCardProps) {
hideJoinButton={props.hideJoinButton}
titleOpensSidePanel={props.titleOpensSidePanel}
compactChrome={props.compactDebateChrome}
+ fullWidth={props.fullWidthDebate}
onPlaybackRequest={props.onDebatePlaybackRequest}
onPlaybackAvailabilityChange={props.onDebatePlaybackAvailabilityChange}
fallback={}
@@ -185,22 +136,12 @@ function BaseExploreFeedCard({
}: ExploreFeedCardProps) {
const isCommunityCall = item.types.some(type => normId(type.id) === COMMUNITY_CALL_EVENT_TYPE);
const isRanking = item.types.some(type => normId(type.id) === RANKING_BLOCK_TYPE);
- const entityHref = `${NavUtils.toEntity(item.spaceId, item.entityId)}#entity-comments`;
- const cardActions = (
-
-
-
- );
+ const cardActions = ;
return (
-
+
{compactDebateChrome ? (
-
+
) : (
)}
@@ -210,7 +151,7 @@ function BaseExploreFeedCard({
) : isRanking ? (
) : (
-
+
+ {count}
+
+ );
+}
diff --git a/apps/web/partials/explore/explore-meta-row.tsx b/apps/web/partials/explore/explore-meta-row.tsx
index a2211ec150..6f041896ca 100644
--- a/apps/web/partials/explore/explore-meta-row.tsx
+++ b/apps/web/partials/explore/explore-meta-row.tsx
@@ -18,7 +18,14 @@ import { SpaceThumb } from './space-thumb';
const RANKING_BLOCK_TYPE = normId(RANKING_BLOCK_TYPE_ID);
-const SEGMENT_CLASS = 'text-[14px] leading-[13px] font-normal tracking-[-0.35px] text-grey-04';
+/**
+ * The type of one metadata segment.
+ *
+ * Exported because the topic card draws a second line of metadata under the description and it has
+ * to be the same type as this row. Spelling it out there instead is precisely how the claim card's
+ * copy of this row lost `font-normal` on three segments without any diff showing it.
+ */
+export const META_SEGMENT_CLASS = 'text-[14px] leading-[13px] font-normal tracking-[-0.35px] text-grey-04';
/**
* The line above every explore card: the space, what the thing is, and when it appeared.
@@ -103,7 +110,7 @@ export function ExploreMetaRow({
key: 'types',
showOnCompactMobile: false,
content: (
-
+
{types.map((type, index) => (
{index > 0 ? : null}
@@ -123,7 +130,7 @@ export function ExploreMetaRow({
segments.push({
key: 'time',
showOnCompactMobile: false,
- content: {timeAgo},
+ content: {timeAgo},
});
}
diff --git a/apps/web/partials/explore/topic-explore-feed-card.test.tsx b/apps/web/partials/explore/topic-explore-feed-card.test.tsx
new file mode 100644
index 0000000000..2e97e411f6
--- /dev/null
+++ b/apps/web/partials/explore/topic-explore-feed-card.test.tsx
@@ -0,0 +1,145 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup, render, screen } from '@testing-library/react';
+
+import type React from 'react';
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { ExploreFeedItem } from '~/core/explore/explore-card-item';
+
+import { TopicExploreFeedCard } from './topic-explore-feed-card';
+
+// The card's job here is the metadata line and the layout around it. The meta row, the title link
+// and the vote/comment row all have their own suites and all reach the network.
+vi.mock('./explore-meta-row', () => ({
+ META_SEGMENT_CLASS: 'meta-segment',
+ ExploreMetaRow: (props: Record) => (
+
+ ),
+}));
+
+vi.mock('./explore-card-title', () => ({
+ ExploreCardTitle: (props: { item: ExploreFeedItem; opensSidePanel: boolean }) => (
+
,
+}));
+
+const item = (overrides: Partial = {}): ExploreFeedItem => ({
+ entityId: 'topic-1',
+ spaceId: 'space-1',
+ spaceName: 'Space One',
+ spaceImage: null,
+ types: [{ id: 'topic-type', name: 'Topic' }],
+ createdAtSec: 0,
+ title: 'AI Safety',
+ description: 'Preventing harm from advanced AI systems.',
+ imageUrl: null,
+ recordingUrls: [],
+ debateVideoUrls: [],
+ debateClaim: null,
+ commentCount: 0,
+ isMemberOrEditor: false,
+ hasPendingMembershipRequest: false,
+ ...overrides,
+});
+
+afterEach(cleanup);
+
+describe('TopicExploreFeedCard', () => {
+ it('names every attached kind, with thousands separated', () => {
+ render();
+
+ expect(screen.getByText(/1,170/)).toBeInTheDocument();
+ expect(screen.getByText(/claims/)).toBeInTheDocument();
+ expect(screen.getByText(/news stories/)).toBeInTheDocument();
+ expect(screen.getByText(/debates/)).toBeInTheDocument();
+ });
+
+ it('leads with debates, then claims, then news stories', () => {
+ const { container } = render(
+
+ );
+
+ // The only paragraph on a card with no description is the metadata line. The dots carry their
+ // spacing as margins rather than as whitespace, so they sit flush in `textContent`.
+ expect(container.querySelector('p')?.textContent).toBe('3 debates·117 claims·2 news stories');
+ });
+
+ it('agrees with the count', () => {
+ render();
+
+ expect(screen.getByText(/claim$/)).toBeInTheDocument();
+ expect(screen.getByText(/news story$/)).toBeInTheDocument();
+ expect(screen.getByText(/debate$/)).toBeInTheDocument();
+ });
+
+ it('leaves out the kinds a topic has none of', () => {
+ render();
+
+ expect(screen.getByText(/claims/)).toBeInTheDocument();
+ expect(screen.queryByText(/news/)).toBeNull();
+ expect(screen.queryByText(/debate/)).toBeNull();
+ });
+
+ it('names the kinds it measured rather than claiming a topic is empty', () => {
+ // A topic also carries episodes, tweets and posts, which this card never counts — so "nothing
+ // attached" would be a claim about buckets it did not measure.
+ render();
+
+ expect(screen.getByText('No debates, claims or news stories yet')).toBeInTheDocument();
+ expect(screen.queryByText('Nothing attached yet')).toBeNull();
+ });
+
+ it('draws no metadata at all when the count could not be read', () => {
+ // A failed count is not a count of zero, and a card must not say it was.
+ render();
+
+ expect(screen.queryByText('Nothing attached yet')).toBeNull();
+ expect(screen.queryByText(/claim/)).toBeNull();
+ });
+
+ it('keeps the generic card around the new line', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('meta-row')).toHaveAttribute('data-hide-join', 'true');
+ expect(screen.getByTestId('title')).toHaveAttribute('data-opens-panel', 'true');
+ expect(screen.getByText('Preventing harm from advanced AI systems.')).toBeInTheDocument();
+ expect(screen.getByTestId('actions')).toBeInTheDocument();
+ });
+
+ it('draws the thumbnail well only for a topic with a picture', () => {
+ // The exact entity href, not a prefix: the comment link points at the same entity with a
+ // `#entity-comments` fragment and is always drawn.
+ const thumbnail = 'a[href="/space/space-1/topic-1"]';
+
+ const { container, rerender } = render();
+ expect(container.querySelector(thumbnail)).toBeNull();
+
+ rerender();
+ expect(container.querySelector(thumbnail)).not.toBeNull();
+ });
+});
diff --git a/apps/web/partials/explore/topic-explore-feed-card.tsx b/apps/web/partials/explore/topic-explore-feed-card.tsx
new file mode 100644
index 0000000000..d0a0847d13
--- /dev/null
+++ b/apps/web/partials/explore/topic-explore-feed-card.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import * as React from 'react';
+
+import cx from 'classnames';
+
+import type { ExploreFeedItem } from '~/core/explore/explore-card-item';
+import type { TopicConnectionCounts } from '~/core/topics/browse/use-topic-connection-counts';
+
+import { EXPLORE_CARD_CLASS, ExploreCardActions, ExploreCardDefaultBody } from './explore-card-chrome';
+import { ExploreMetaRow, META_SEGMENT_CLASS } from './explore-meta-row';
+import { MetaDot } from './meta-dot';
+
+/**
+ * A Topic in an explore feed.
+ *
+ * The generic card, plus the one thing it cannot say. A topic has no verdict, no video and no
+ * position to take — what distinguishes one from the next is how much hangs off it — and the
+ * generic card renders it as a name and a description, which is the same card an empty topic gets.
+ *
+ * So this *is* the generic card: the shared frame, meta row, body and actions from
+ * `explore-card-chrome`, with the counts passed into the body's `meta` slot. Reproducing the body
+ * here instead would have been the third copy of that layout in this directory, and the first two
+ * drifted.
+ *
+ * Not wired into `ExploreFeedCard`'s type dispatch. The claim page's Topics tab is the surface that
+ * asked for this and the surface that can pay for it: the counts are a second request, and the main
+ * explore feed pre-mounts cards thousands of pixels below the fold. A Topic drawn anywhere else
+ * keeps exactly the card it has today.
+ */
+export function TopicExploreFeedCard({
+ item,
+ counts,
+ hideSpaceLink = false,
+ hideJoinButton = false,
+ titleOpensSidePanel = false,
+}: {
+ item: ExploreFeedItem;
+ /**
+ * What is attached to this topic. `null` when the count could not be read, which draws no
+ * metadata line at all rather than a row of zeros.
+ */
+ counts: TopicConnectionCounts | null;
+ hideSpaceLink?: boolean;
+ hideJoinButton?: boolean;
+ titleOpensSidePanel?: boolean;
+}) {
+ return (
+
+
+ }
+ actions={}
+ />
+
+ );
+}
+
+/** "3 debates · 117 claims · 2 news stories", in the meta row's own type and separator. */
+export function TopicConnectionMeta({
+ counts,
+ className,
+}: {
+ counts: TopicConnectionCounts | null;
+ className?: string;
+}) {
+ if (!counts) return null;
+
+ // Debates first, then claims, then news stories: rarest and most specific to least. A topic with
+ // debates on it is the interesting case and the count that most often distinguishes two topics,
+ // and it would otherwise be the segment most likely to be pushed onto a second line.
+ const segments = [
+ { key: 'debates', count: counts.debates, noun: ['debate', 'debates'] as const },
+ { key: 'claims', count: counts.claims, noun: ['claim', 'claims'] as const },
+ { key: 'news', count: counts.news, noun: ['news story', 'news stories'] as const },
+ ].filter(segment => segment.count > 0);
+
+ // Zeros are dropped rather than printed: "0 news stories" on a topic that is entirely claims is
+ // three words about something that isn't there. A topic with none of the three says so once.
+ //
+ // Named rather than summarised: a topic also carries episodes, tweets, posts and the rest — the
+ // topic page's composition strip counts six kinds and a remainder — so "nothing attached" would
+ // be a claim about buckets this card never measured. On a claim's Topics tab the line is close
+ // to unreachable anyway, since the claim being read is itself a claim attached to every topic
+ // listed; it can show while that relation is still indexing, and wherever else the card is used.
+ if (segments.length === 0) {
+ return
+ );
+}
diff --git a/apps/web/partials/profile/person-record-feed.test.tsx b/apps/web/partials/profile/person-record-feed.test.tsx
index 8f41d1c13b..147877f7c3 100644
--- a/apps/web/partials/profile/person-record-feed.test.tsx
+++ b/apps/web/partials/profile/person-record-feed.test.tsx
@@ -123,6 +123,24 @@ describe('PersonRecordFeed', () => {
expect(screen.queryByText('Couldn’t load more positions.')).not.toBeInTheDocument();
});
+ it('draws the shared card when the caller supplies none', () => {
+ renderFeed({ rows: [row('a'), row('b')] });
+
+ expect(screen.getAllByTestId('card').map(card => card.textContent)).toEqual(['a', 'b']);
+ });
+
+ it('lets a caller draw its own card, keyed for it', () => {
+ // The Topics tab's card carries counts this component cannot fetch; everything around the rows
+ // is the same, which is why it is a render function rather than a second copy of this file.
+ renderFeed({
+ rows: [row('a'), row('b')],
+ renderCard: item =>
{item.entityId}
,
+ });
+
+ expect(screen.getAllByTestId('custom').map(card => card.textContent)).toEqual(['a', 'b']);
+ expect(screen.queryByTestId('card')).toBeNull();
+ });
+
it('offers no retry when nothing failed', () => {
renderFeed({ rows: [row('claim-1')], fetchNextPage: () => {} });
diff --git a/apps/web/partials/profile/person-record-feed.tsx b/apps/web/partials/profile/person-record-feed.tsx
index f0fdbfcecd..a63d5bd3f5 100644
--- a/apps/web/partials/profile/person-record-feed.tsx
+++ b/apps/web/partials/profile/person-record-feed.tsx
@@ -2,7 +2,7 @@
import * as React from 'react';
-import { type ExploreFeedRow, toExploreFeedItem } from '~/core/explore/explore-card-item';
+import { type ExploreFeedItem, type ExploreFeedRow, toExploreFeedItem } from '~/core/explore/explore-card-item';
import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels';
import type { ClaimResponse } from '~/core/profile/person-position-order';
import { useInfiniteSentinel } from '~/core/profile/use-infinite-sentinel';
@@ -41,6 +41,8 @@ export function PersonRecordFeed({
noun,
responseByClaimId,
personName,
+ fullWidthDebates = false,
+ renderCard,
}: {
rows: ExploreFeedRow[];
isLoading: boolean;
@@ -82,6 +84,19 @@ export function PersonRecordFeed({
* beside them says nothing about which of the two people it describes.
*/
personName?: string | null;
+ /** Debates fill the column instead of their viewport-fitted cap. */
+ fullWidthDebates?: boolean;
+ /**
+ * Draws a row with something other than the shared `ExploreFeedCard`.
+ *
+ * The claim page's Topics tab passes one: a topic gets a card of its own, carrying counts this
+ * component has no way to fetch. Everything around the rows — the space lookup, the item
+ * projection, and the loading, empty, error and partial-failure states — is identical whichever
+ * card is drawn, and is the reason this takes a render function rather than being copied.
+ *
+ * The key stays here, so a caller cannot forget it.
+ */
+ renderCard?: (item: ExploreFeedItem) => React.ReactNode;
}) {
// Looked up once for the page. These are routinely spaces the viewer has never
// opened, which the browse sidebar cannot name.
@@ -128,30 +143,34 @@ export function PersonRecordFeed({
*/}
{items.map(item => (
- (
-
- )
- : undefined
- }
- hideJoinButton
- // The claim opens in the side panel rather than navigating, as it
- // does on Explore: this is a list somebody is reading down, and
- // losing the page to read one row is a worse trade here than it is
- // anywhere.
- titleOpensSidePanel
- />
+
+ {renderCard?.(item) ?? (
+ (
+
+ )
+ : undefined
+ }
+ hideJoinButton
+ // The claim opens in the side panel rather than navigating, as it
+ // does on Explore: this is a list somebody is reading down, and
+ // losing the page to read one row is a worse trade here than it is
+ // anywhere.
+ titleOpensSidePanel
+ />
+ )}
+
))}
diff --git a/apps/web/partials/space-page/space-member-row.tsx b/apps/web/partials/space-page/space-member-row.tsx
index e2dc3d763f..25b01515d6 100644
--- a/apps/web/partials/space-page/space-member-row.tsx
+++ b/apps/web/partials/space-page/space-member-row.tsx
@@ -1,3 +1,5 @@
+import cx from 'classnames';
+
import { OmitStrict, Profile } from '~/core/types';
import { formatShortAddress } from '~/core/utils/utils';
@@ -6,11 +8,12 @@ import { PrefetchLink as Link } from '~/design-system/prefetch-link';
interface EditorRowProps {
user: OmitStrict;
+ className?: string;
}
-export function MemberRow({ user }: EditorRowProps) {
+export function MemberRow({ user, className }: EditorRowProps) {
return (
-
+