From dc02b178f99e2b29375b2753e6fa66752bfe0aed Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:14:46 +0100 Subject: [PATCH 01/11] feat(claims): lead the claim page with its verdict and position controls - The title (entityTitle), type/tag chips and the verdict form one block above the tabs, with Agree/Disagree inside the verdict and no card border or padding. 48px between the chips, the verdict and the tabs. - The percentage uses mainPage in the text colour; 12px above the bar, 8px below it, and no swatch dots on the sides row. - Controversial is a red chip beside the type and tag chips, in the chips' own size and type. - The responder list is titled "6 people agree" above the rows, puts people with a profile picture first, and highlights rows on hover like the browse sidebar. - The Debates tab lets a debate fill the content column. - Brings in the Activity section header from #2514 (pills, View all, header arrows, 12px compact player corners). Co-Authored-By: Claude Opus 5 (1M context) --- .../claims/browse/claim-page-view.test.tsx | 21 ++- .../core/claims/browse/claim-page-view.tsx | 145 +++++++++--------- .../core/claims/browse/claim-record-tab.tsx | 19 +-- .../claims/browse/claim-side-responders.tsx | 32 ++-- apps/web/core/claims/browse/claim-summary.tsx | 15 +- .../core/claims/browse/claim-verdict.test.tsx | 41 +++++ apps/web/core/claims/browse/claim-verdict.tsx | 58 +++++-- .../explore/debate-explore-feed-card.tsx | 13 +- .../partials/explore/explore-feed-card.tsx | 10 +- .../partials/profile/person-record-feed.tsx | 4 + .../partials/space-page/space-member-row.tsx | 7 +- 11 files changed, 244 insertions(+), 121 deletions(-) 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..39601b1602 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -141,7 +141,9 @@ vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => () => { vi.mock('~/core/debates/backfill-readiness-for-held-position', () => ({ useBackfillReadinessForHeldPosition: () => {}, })); -vi.mock('./claim-verdict', () => ({ ClaimVerdict: () =>
})); +vi.mock('./claim-verdict', () => ({ + ClaimVerdict: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})); vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () =>
})); vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null })); vi.mock('./claim-record-tab', () => ({ @@ -261,16 +263,27 @@ describe('ClaimPageView record', () => { ]); }); - it('orders Overview as response summary, position, activity, then comments', () => { + it('heads the page with the claim, its verdict and the position controls, above the tabs', () => { render(); + const heading = screen.getByRole('heading', { level: 1 }); const position = screen.getByTestId('position'); const verdict = screen.getByTestId('verdict'); + const tabs = screen.getByTestId('tabs'); + + expect(verdict).toContainElement(position); + expect(heading.compareDocumentPosition(verdict) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(verdict.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + 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(); }); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 8d73469d58..f037c4329c 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -22,6 +22,7 @@ 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 { Skeleton } from '~/design-system/skeleton'; import { Text } from '~/design-system/text'; @@ -34,7 +35,6 @@ import { } 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 { type ActivityKind, ProfileActivitySection } from '~/partials/profile/profile-activity-section'; import { SPACE_TABS_ANCHOR } from '~/partials/space-page/space-tabs-anchor'; @@ -42,7 +42,6 @@ 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 { useClaimRecord } from './use-claim-record'; import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state'; @@ -186,21 +185,22 @@ 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, + {/* The hero and the tabs share a fixed 48px gap rather than the page's, which is 24px below + 560px and 32px above: 48px is the space above the verdict too, so the block sits evenly. */} +
+ {/* 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 + {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 @@ -210,49 +210,70 @@ export function ClaimPageView({ `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 ? ( - - ) : ( - entity.description && ( - - ) - )} - - {/* What this is. Topics — what it is *about* — used to sit opposite these, pushed to the + ) : ( + 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} +
+ {typeName && {typeName}} + {tags.map(tag => ( + {tag.toEntity.name ?? tag.toEntity.id} + ))} + {/* Among the chips that say what this is, which is what "contested" is. 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. */} + {summary.isControversial ? ( + + + Controversial + + ) : null} +
+ + {/* The verdict and the reader's own side, in the hero with the claim they answer rather + than under the tabs: they are the page's point, so they don't move when the tab does. + The position controls sit under the split, so a reader sees where opinion stands and + adds to it in the same place. */} + + + +
+ +
+ tab.label)} + divideBeforeAuthored + />
-
- -
- tab.label)} - divideBeforeAuthored - />
; topics: Relation[]; availableSpaceIds: string[]; @@ -364,12 +379,6 @@ 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 @@ -393,7 +402,7 @@ function ClaimTabPanel({ /** * Taking a side, and standing ready to argue it. * - * Both live in one card, with the readiness switch in the header's top right and the side pills + * Both live together, inside the verdict in the hero, with the readiness switch in the header's top right and the side pills * beneath — the same arrangement the hub's claim card uses, so the switch is where anyone who has * used the panel already looks for it. They belong together because they are a sequence: readiness * can only be turned *on* for a claim you have already responded to. @@ -438,10 +447,8 @@ function ClaimPositionSection({ useBackfillReadinessForHeldPosition({ readiness: row, entityId, spaceId }); return ( -
- {/* 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 inside the verdict, under the split. +
('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". */} +

+ {totalCount} {pluralize('person', totalCount)}{' '} + {totalCount === 1 ? pluralize(label.toLowerCase()) : label.toLowerCase()} +

{/* Contained, so a wheel past the end of the list does not chain through to the page behind. */} -
+ {/* Inset, so each row's hover sits inside the box as a rounded highlight — the browse sidebar's + own rows, in shape and colour. */} +
{isLoading || !profiles ? ( ) : ( - profiles.map(profile => ) + sortedProfiles.map(profile => ( + + )) )}
-
-

- {totalCount} {pluralize('person', totalCount)} -

-

{label}

-
); } diff --git a/apps/web/core/claims/browse/claim-summary.tsx b/apps/web/core/claims/browse/claim-summary.tsx index 232abb674c..d854ffc420 100644 --- a/apps/web/core/claims/browse/claim-summary.tsx +++ b/apps/web/core/claims/browse/claim-summary.tsx @@ -187,6 +187,7 @@ export function ClaimSides({ summary, className, alignSecondEnd = false, + hideSwatches = false, }: { entityId: string; spaceId: string; @@ -195,6 +196,11 @@ export function ClaimSides({ className?: string; /** True where the two sit on one row with room between them, so the second reads as its end. */ alignSecondEnd?: boolean; + /** + * Drop the green and red dots. For a surface that draws the split bar directly above, whose two + * colours already say which side is which. + */ + hideSwatches?: boolean; }) { const copy = ENTITY_RESPONSE_COPY[responseKind]; const shared = { @@ -209,14 +215,14 @@ export function ClaimSides({
- + {swatchClassName ? : null} {/* Count first, and the verb lowercase: "12 agree" is a sentence, where "Agree 12" is a label with a number stuck to it. It also matches the share directly above — "68% agree" — so the three lines of the verdict read in one voice instead of two. */} diff --git a/apps/web/core/claims/browse/claim-verdict.test.tsx b/apps/web/core/claims/browse/claim-verdict.test.tsx index ed2a472cd4..955b390b8b 100644 --- a/apps/web/core/claims/browse/claim-verdict.test.tsx +++ b/apps/web/core/claims/browse/claim-verdict.test.tsx @@ -78,4 +78,45 @@ describe('ClaimVerdict', () => { expect(screen.queryByText('No responses yet')).toBeNull(); expect(container.querySelector('.animate-pulse')).not.toBeNull(); }); + + // The claim page's Agree/Disagree renders in this card. Taking a side doesn't wait on a count, so + // the controls stay reachable in every state the numbers can be in. + describe('with children', () => { + function renderWithControls(value: ClaimResponseSummary) { + return render( + +
+ + ); + } + + it('renders them in the same card as the split', () => { + renderWithControls(summary({ ...summarizeClaimResponses(17, 3) })); + + const card = screen.getByRole('region', { name: 'Response summary' }); + expect(card).toContainElement(screen.getByTestId('claim-split-bar')); + expect(card).toContainElement(screen.getByTestId('controls')); + }); + + it('renders them under the invitation on an unanswered claim', () => { + renderWithControls(summary()); + + expect(screen.getByText('No responses yet')).toBeInTheDocument(); + expect(screen.getByTestId('controls')).toBeInTheDocument(); + }); + + it('still renders them when the counts never answered', () => { + renderWithControls(summary({ hasCounts: false })); + + expect(screen.queryByText('No responses yet')).toBeNull(); + expect(screen.getByTestId('controls')).toBeInTheDocument(); + }); + + it('still renders them while the counts load', () => { + const { container } = renderWithControls(summary({ isLoading: true, hasCounts: false })); + + expect(container.querySelector('.animate-pulse')).not.toBeNull(); + expect(screen.getByTestId('controls')).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/core/claims/browse/claim-verdict.tsx b/apps/web/core/claims/browse/claim-verdict.tsx index 3b75e3aec6..d10569df4b 100644 --- a/apps/web/core/claims/browse/claim-verdict.tsx +++ b/apps/web/core/claims/browse/claim-verdict.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; +import cx from 'classnames'; + import { ENTITY_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response'; import { Skeleton } from '~/design-system/skeleton'; @@ -23,20 +25,28 @@ import { ClaimSides, ClaimSplitBar } from './claim-summary'; * 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. + * + * `children` render in the same block, under the split: the claim page puts the reader's own + * Agree/Disagree there, so the result and the way to add to it read as one block. They render + * whatever the counts are doing, since taking a side doesn't wait on a count. */ export function ClaimVerdict({ entityId, spaceId, responseKind, summary, + children, + className, }: { entityId: string; spaceId: string; responseKind: ResponseKind; summary: ClaimResponseSummary; + children?: React.ReactNode; + className?: string; }) { - if (summary.isLoading) { - return ; + if (summary.isLoading && !children) { + return ; } // Nothing, where the counts never answered. @@ -50,8 +60,35 @@ export function ClaimVerdict({ // // 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 stats = summary.isLoading ? ( + + ) : summary.hasCounts ? ( + + ) : null; + + if (!stats && !children) return null; + + return ( + // No border or padding of its own: the claim page draws it in the hero, as part of the block + // the claim's title heads. +
+ {stats} + {children ?
{children}
: null} +
+ ); +} +function ClaimVerdictStats({ + entityId, + spaceId, + responseKind, + summary, +}: { + entityId: string; + spaceId: string; + responseKind: ResponseKind; + summary: ClaimResponseSummary; +}) { const copy = ENTITY_RESPONSE_COPY[responseKind]; const tier = claimSummaryTier(summary.total); @@ -59,28 +96,28 @@ export function ClaimVerdict({ // 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}% + {percent}% {/* "Agreements" → "agree", "Verifications" → "verify" reads wrong; use the action verb. */} {copy.positiveAction.toLowerCase()} @@ -93,16 +130,17 @@ export function ClaimVerdict({
- + -
+ ); } diff --git a/apps/web/partials/explore/debate-explore-feed-card.tsx b/apps/web/partials/explore/debate-explore-feed-card.tsx index 5a3c478827..ca5b775273 100644 --- a/apps/web/partials/explore/debate-explore-feed-card.tsx +++ b/apps/web/partials/explore/debate-explore-feed-card.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; +import cx from 'classnames'; + import type { Debate } from '~/core/debates/api'; import { DebateClaimsPanel } from '~/core/debates/browse/debate-claims-panel'; import { DebateFeedPlayer } from '~/core/debates/browse/debate-feed-player'; @@ -75,6 +77,12 @@ type DebateExploreFeedCardProps = { titleOpensSidePanel?: boolean; /** Compact title and metadata treatment used by the narrow profile Activity rail. */ compactChrome?: boolean; + /** + * Drop the viewport-fitted column cap and fill the container. 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. + */ + 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 +116,7 @@ export function DebateExploreFeedCard({ hideJoinButton = false, titleOpensSidePanel = false, compactChrome = false, + fullWidth = false, onPlaybackRequest, onPlaybackAvailabilityChange, fallback, @@ -301,8 +310,8 @@ export function DebateExploreFeedCard({ alone is what lines "Join a debate" up with the videos' right edge instead of the card's, 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-feed-card.tsx b/apps/web/partials/explore/explore-feed-card.tsx index 672ca33133..e3a495a579 100644 --- a/apps/web/partials/explore/explore-feed-card.tsx +++ b/apps/web/partials/explore/explore-feed-card.tsx @@ -42,6 +42,8 @@ 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. */ @@ -149,6 +151,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={} @@ -195,12 +198,7 @@ function BaseExploreFeedCard({ return (
{compactDebateChrome ? ( - + ) : ( )} diff --git a/apps/web/partials/profile/person-record-feed.tsx b/apps/web/partials/profile/person-record-feed.tsx index f0fdbfcecd..53beb5b8ea 100644 --- a/apps/web/partials/profile/person-record-feed.tsx +++ b/apps/web/partials/profile/person-record-feed.tsx @@ -41,6 +41,7 @@ export function PersonRecordFeed({ noun, responseByClaimId, personName, + fullWidthDebates = false, }: { rows: ExploreFeedRow[]; isLoading: boolean; @@ -82,6 +83,8 @@ 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; }) { // Looked up once for the page. These are routinely spaces the viewer has never // opened, which the browse sidebar cannot name. @@ -131,6 +134,7 @@ export function PersonRecordFeed({ ; + className?: string; } -export function MemberRow({ user }: EditorRowProps) { +export function MemberRow({ user, className }: EditorRowProps) { return ( - +
From b4a23c51ae0b4e46a802fe21b917799ac25bc2c4 Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:17:53 +0100 Subject: [PATCH 02/11] fix(claims): 36px between the claim's chips and its verdict Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/core/claims/browse/claim-page-view.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index f037c4329c..6bb3fa7075 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -186,7 +186,7 @@ export function ClaimPageView({ style={{ maxWidth: CLAIM_PAGE_CONTENT_MAX_WIDTH }} > {/* The hero and the tabs share a fixed 48px gap rather than the page's, which is 24px below - 560px and 32px above: 48px is the space above the verdict too, so the block sits evenly. */} + 560px and 32px above. */}
{/* Hero */}
@@ -256,8 +256,8 @@ export function ClaimPageView({ spaceId={spaceId} responseKind={responseKind} summary={summary} - // With the header's 12px gap, 48px above the verdict: the same as below it. - className="mt-9" + // With the header's 12px gap, 36px between the chips and the verdict. + className="mt-6" > From f36066e297199302b19cd46e7db70aa280b58784 Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:15:51 +0100 Subject: [PATCH 03/11] feat(claims): lay the claim hero out like the Explore claim card, and add a Topics tab - The hero is the Explore claim card at page scale: the claim and its Agree/Disagree pills on the left, Explore's own 220px verdict column on the right behind a full-height rule, starting level with the title. Under a 520px container it stacks, as the card does. A claim nobody has answered takes the full width. - ClaimVerdictColumn is exported from the Explore card for this; the page's own ClaimVerdict is removed, since nothing else used it. - The title and the verdict's type go back to their original sizes; the type and tag chips are gone, and Controversial sits above the title. - Topics move to a Topics tab (/topics), listed uncapped. The hero's capped topic row is built but off (SHOW_HERO_TOPICS), and the Overview no longer repeats them. - The page column is 840px, for 800px of content. Co-Authored-By: Claude Opus 5 (1M context) --- .../[id]/[entityId]/claim-record-page.tsx | 2 +- .../(entity)/[id]/[entityId]/topics/page.tsx | 5 + .../claims/browse/claim-page-view.test.tsx | 79 ++++-- .../core/claims/browse/claim-page-view.tsx | 263 +++++++++++------- .../core/claims/browse/claim-verdict.test.tsx | 122 -------- apps/web/core/claims/browse/claim-verdict.tsx | 146 ---------- .../entity-page/relation-chip-section.tsx | 9 +- .../explore/claim-explore-feed-card.tsx | 6 +- 8 files changed, 241 insertions(+), 391 deletions(-) create mode 100644 apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx delete mode 100644 apps/web/core/claims/browse/claim-verdict.test.tsx delete mode 100644 apps/web/core/claims/browse/claim-verdict.tsx 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 39601b1602..916ddc3b4b 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -13,6 +13,8 @@ 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, /** 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. */ @@ -101,6 +103,9 @@ vi.mock('./use-claim-response-state', () => ({ useClaimResponseState: () => ({ responseKind: 'stance', summary: { + isLoading: false, + hasCounts: true, + total: mocks.responseTotal, isControversial: false, viewerDirection: 'positive', viewerSpaceId: 'viewer-space', @@ -138,11 +143,19 @@ 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: ({ children }: { children?: React.ReactNode }) =>
{children}
, +vi.mock('~/partials/explore/claim-explore-feed-card', () => ({ + ClaimVerdictColumn: () =>
, })); vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () =>
})); vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null })); @@ -187,6 +200,7 @@ function claimEntity(description: string | null) { } beforeEach(() => { + mocks.responseTotal = 11; mocks.entity = claimEntity('A description long enough that the page has something to collapse.'); mocks.clamp = null; mocks.chipSection = null; @@ -263,7 +277,12 @@ describe('ClaimPageView record', () => { ]); }); - it('heads the page with the claim, its verdict and the position controls, above the tabs', () => { + 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 }); @@ -271,9 +290,21 @@ describe('ClaimPageView record', () => { const verdict = screen.getByTestId('verdict'); const tabs = screen.getByTestId('tabs'); - expect(verdict).toContainElement(position); - expect(heading.compareDocumentPosition(verdict) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // 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('orders Overview as activity, then comments', () => { @@ -401,38 +432,38 @@ 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 them with the shared chip section, under the label Topics', () => { - mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation] }; + 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), tagRelation] }; render(); expect(screen.getByTestId('chip-section')).toHaveAttribute('data-label', 'Topics'); - expect(screen.getByTestId('activity').nextElementSibling).toBe(screen.getByTestId('chip-section')); + expect(mocks.chipSection?.relations).toEqual([topic(1)]); + expect(mocks.chipSection?.cap).toBe(Infinity); + expect(mocks.chipSection?.spaceId).toBe('space-1'); }); - 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(); }); - // 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 6bb3fa7075..7dfa0777d0 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'; @@ -23,6 +23,7 @@ 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'; @@ -35,6 +36,7 @@ import { } 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 { 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,15 +44,18 @@ import { ClaimEndSlot } from './claim-end-slot'; import { ClaimRecordTab } from './claim-record-tab'; import { getClaimSources } from './claim-sources'; import { ClaimSourcesTab } from './claim-sources-tab'; -import { ClaimVerdict } from './claim-verdict'; 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({ @@ -68,6 +73,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'; } @@ -75,6 +81,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'; } @@ -127,13 +134,12 @@ 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; 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; const requestedTab = resolveClaimTab({ pathname, @@ -150,6 +156,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. @@ -159,11 +166,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' }] : []), ]; @@ -188,79 +197,90 @@ export function ClaimPageView({ {/* The hero and the tabs share a fixed 48px gap rather than the page's, which is 24px below 560px and 32px above. */}
- {/* 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 ? ( - - ) : ( - entity.description && ( - +
+ {/* 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. */} + {(SHOW_HERO_TOPICS && topics.length > 0) || summary.isControversial ? ( + sidePanelTab.setActiveSystemTab('topics') : undefined} + isControversial={summary.isControversial} + className="col-span-full row-start-1 mb-3 claim-card-narrow:mb-0" /> - ) - )} - - {/* 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. 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. */} - {summary.isControversial ? ( - - - Controversial - ) : null} -
- {/* The verdict and the reader's own side, in the hero with the claim they answer rather - than under the tabs: they are the page's point, so they don't move when the tab does. - The position controls sit under the split, so a reader sees where opinion stands and - adds to it in the same place. */} - - - +
+ {/* `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} +
@@ -346,6 +366,11 @@ function ClaimTabPanel({ ); } + if (activeTab === 'topics') { + // Every topic, uncapped: this tab is where the hero's "See all" leads. + return ; + } + if (activeTab === 'sources') { return ; } @@ -380,10 +405,6 @@ function ClaimTabPanel({ return ( <> - {/* 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. */} {control.responseError ? (
@@ -488,19 +511,71 @@ 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`; + return ( - - {children} - + ); } 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 955b390b8b..0000000000 --- a/apps/web/core/claims/browse/claim-verdict.test.tsx +++ /dev/null @@ -1,122 +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(); - }); - - // The claim page's Agree/Disagree renders in this card. Taking a side doesn't wait on a count, so - // the controls stay reachable in every state the numbers can be in. - describe('with children', () => { - function renderWithControls(value: ClaimResponseSummary) { - return render( - -
- - ); - } - - it('renders them in the same card as the split', () => { - renderWithControls(summary({ ...summarizeClaimResponses(17, 3) })); - - const card = screen.getByRole('region', { name: 'Response summary' }); - expect(card).toContainElement(screen.getByTestId('claim-split-bar')); - expect(card).toContainElement(screen.getByTestId('controls')); - }); - - it('renders them under the invitation on an unanswered claim', () => { - renderWithControls(summary()); - - expect(screen.getByText('No responses yet')).toBeInTheDocument(); - expect(screen.getByTestId('controls')).toBeInTheDocument(); - }); - - it('still renders them when the counts never answered', () => { - renderWithControls(summary({ hasCounts: false })); - - expect(screen.queryByText('No responses yet')).toBeNull(); - expect(screen.getByTestId('controls')).toBeInTheDocument(); - }); - - it('still renders them while the counts load', () => { - const { container } = renderWithControls(summary({ isLoading: true, hasCounts: false })); - - expect(container.querySelector('.animate-pulse')).not.toBeNull(); - expect(screen.getByTestId('controls')).toBeInTheDocument(); - }); - }); -}); 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 d10569df4b..0000000000 --- a/apps/web/core/claims/browse/claim-verdict.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client'; - -import * as React from 'react'; - -import cx from 'classnames'; - -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. - * - * `children` render in the same block, under the split: the claim page puts the reader's own - * Agree/Disagree there, so the result and the way to add to it read as one block. They render - * whatever the counts are doing, since taking a side doesn't wait on a count. - */ -export function ClaimVerdict({ - entityId, - spaceId, - responseKind, - summary, - children, - className, -}: { - entityId: string; - spaceId: string; - responseKind: ResponseKind; - summary: ClaimResponseSummary; - children?: React.ReactNode; - className?: string; -}) { - if (summary.isLoading && !children) { - 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. - const stats = summary.isLoading ? ( - - ) : summary.hasCounts ? ( - - ) : null; - - if (!stats && !children) return null; - - return ( - // No border or padding of its own: the claim page draws it in the hero, as part of the block - // the claim's title heads. -
- {stats} - {children ?
{children}
: null} -
- ); -} - -function ClaimVerdictStats({ - entityId, - spaceId, - responseKind, - summary, -}: { - entityId: string; - spaceId: string; - responseKind: ResponseKind; - summary: ClaimResponseSummary; -}) { - 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/partials/entity-page/relation-chip-section.tsx b/apps/web/partials/entity-page/relation-chip-section.tsx index e01dcd93d9..92c7e1ccfd 100644 --- a/apps/web/partials/entity-page/relation-chip-section.tsx +++ b/apps/web/partials/entity-page/relation-chip-section.tsx @@ -44,11 +44,14 @@ export function RelationChipSection({ label, relations, spaceId, + cap = CHIP_CAP, }: { /** Also the section's accessible name, so the two can never disagree. */ label: string; relations: Relation[]; spaceId: string; + /** How many chips show before the `+N`. `Infinity` for a surface that is already the full list. */ + cap?: number; }) { const [expanded, setExpanded] = React.useState(false); const chipsRef = React.useRef(null); @@ -79,12 +82,12 @@ export function RelationChipSection({ React.useEffect(() => { if (!expanded || !focusAfterExpandRef.current) return; focusAfterExpandRef.current = false; - chipsRef.current?.querySelectorAll('a')[CHIP_CAP]?.focus(); - }, [expanded]); + chipsRef.current?.querySelectorAll('a')[cap]?.focus(); + }, [cap, expanded]); if (relations.length === 0) return null; - const visible = expanded ? relations : relations.slice(0, CHIP_CAP); + const visible = expanded ? relations : relations.slice(0, cap); const hidden = relations.length - visible.length; return ( 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, From 943a09bc97b0b28641e8284968c80b95218fea77 Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:16:36 +0100 Subject: [PATCH 04/11] chore(claims): drop ClaimSides' hideSwatches, which nothing uses since the verdict moved to Explore's column Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/core/claims/browse/claim-summary.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/web/core/claims/browse/claim-summary.tsx b/apps/web/core/claims/browse/claim-summary.tsx index d854ffc420..232abb674c 100644 --- a/apps/web/core/claims/browse/claim-summary.tsx +++ b/apps/web/core/claims/browse/claim-summary.tsx @@ -187,7 +187,6 @@ export function ClaimSides({ summary, className, alignSecondEnd = false, - hideSwatches = false, }: { entityId: string; spaceId: string; @@ -196,11 +195,6 @@ export function ClaimSides({ className?: string; /** True where the two sit on one row with room between them, so the second reads as its end. */ alignSecondEnd?: boolean; - /** - * Drop the green and red dots. For a surface that draws the split bar directly above, whose two - * colours already say which side is which. - */ - hideSwatches?: boolean; }) { const copy = ENTITY_RESPONSE_COPY[responseKind]; const shared = { @@ -215,14 +209,14 @@ export function ClaimSides({
- {swatchClassName ? : null} + {/* Count first, and the verb lowercase: "12 agree" is a sentence, where "Agree 12" is a label with a number stuck to it. It also matches the share directly above — "68% agree" — so the three lines of the verdict read in one voice instead of two. */} From 116e39a2e4ea9ccebdb0851f0acb22d296fc0440 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 22 Sep 2026 09:57:08 -0700 Subject: [PATCH 05/11] feat(claims): make the Topics tab an explore feed, and give a topic a card that says how big it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Topics tab landed as a row of chips. That is the right shape for a header strip and the wrong one for a tab: a chip carries a name and nothing else, so a tab of them is a list with no way to tell which entry is worth opening — and it is a list the hero's "See all" deliberately sends people to. Topics now render as explore cards, ordered by how much is attached to them. The card is the generic explore card down to the class — same meta row, same 60px thumbnail well, same title and two-line description, same vote/comment row — plus one line of metadata between the description and the actions: claims, news stories, and the debates argued on those claims. Kinds a topic has none of are left out rather than printed as zeros; a topic with nothing attached says so once. A count that could not be read draws no line at all, because a failed count is not a count of zero. The counts are asked of `entitiesConnection` rather than of `relationsConnection`, which is faster and is what the topic page's composition strip uses. It counts links, not things: the same claim carrying `Topics` in two spaces is two relations, and on `AI governance` that is 552 relations against 505 claims. The number here also orders the list, so it has to be a count of things. Debates are two hops — debate to claim, claim to topic — which is what "debates connected to the attached claims" means. One request for the whole tab: each topic contributes three aliased counts, and the parsed document is cached by topic count since the ids are variables. Five topics measured ~0.75s against testnet. Nothing paints until both the rows and the counts are in. They are two requests, and painting the first would show the claim's own topic order and resequence it under the reader a beat later — the trade `useTopicLinkedEntities` already makes. Also here: - `ExploreFeedCommentLink` moves to its own module so the topic card can draw the same link without closing an import cycle through `explore-feed-card`. - `RelationChipSection`'s `cap` prop is reverted. It was added for this tab to pass `Infinity`, and this tab no longer draws chips; Subtopics, its only remaining caller, uses the default. - The topic card is deliberately *not* wired into `ExploreFeedCard`'s type dispatch. The counts are a second request and the main feed pre-mounts cards thousands of pixels below the fold, so a Topic drawn anywhere else keeps the card it has today. --- .../claims/browse/claim-page-view.test.tsx | 33 +++- .../core/claims/browse/claim-page-view.tsx | 8 +- .../claims/browse/claim-topics-tab.test.tsx | 187 ++++++++++++++++++ .../core/claims/browse/claim-topics-tab.tsx | 101 ++++++++++ .../use-topic-connection-counts.test.ts | 76 +++++++ .../browse/use-topic-connection-counts.ts | 171 ++++++++++++++++ .../entity-page/relation-chip-section.tsx | 9 +- .../partials/explore/explore-feed-card.tsx | 11 +- .../explore/explore-feed-comment-link.tsx | 22 +++ .../explore/topic-explore-feed-card.test.tsx | 128 ++++++++++++ .../explore/topic-explore-feed-card.tsx | 136 +++++++++++++ 11 files changed, 857 insertions(+), 25 deletions(-) create mode 100644 apps/web/core/claims/browse/claim-topics-tab.test.tsx create mode 100644 apps/web/core/claims/browse/claim-topics-tab.tsx create mode 100644 apps/web/core/topics/browse/use-topic-connection-counts.test.ts create mode 100644 apps/web/core/topics/browse/use-topic-connection-counts.ts create mode 100644 apps/web/partials/explore/explore-feed-comment-link.tsx create mode 100644 apps/web/partials/explore/topic-explore-feed-card.test.tsx create mode 100644 apps/web/partials/explore/topic-explore-feed-card.tsx 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 916ddc3b4b..42eb69552a 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({ 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, @@ -82,7 +84,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) => { @@ -91,6 +94,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 }), })); @@ -204,6 +216,7 @@ beforeEach(() => { 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; @@ -444,13 +457,20 @@ describe('ClaimPageView topics', () => { 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), tagRelation] }; + 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('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(mocks.chipSection?.relations).toEqual([topic(1)]); - expect(mocks.chipSection?.cap).toBe(Infinity); - expect(mocks.chipSection?.spaceId).toBe('space-1'); + expect(screen.queryByTestId('chip-section')).toBeNull(); }); it('no longer repeats them on the Overview', () => { @@ -458,6 +478,7 @@ describe('ClaimPageView topics', () => { render(); expect(screen.queryByTestId('chip-section')).toBeNull(); + expect(screen.queryByTestId('topics-tab')).toBeNull(); }); it('offers a Topics tab only when the claim has topics', () => { diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 7dfa0777d0..1c5d272ac3 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -35,7 +35,7 @@ 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 { 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'; @@ -44,6 +44,7 @@ import { ClaimEndSlot } from './claim-end-slot'; import { ClaimRecordTab } from './claim-record-tab'; import { getClaimSources } from './claim-sources'; import { ClaimSourcesTab } from './claim-sources-tab'; +import { ClaimTopicsTab } from './claim-topics-tab'; import { useClaimRecord } from './use-claim-record'; import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state'; @@ -367,8 +368,9 @@ function ClaimTabPanel({ } if (activeTab === 'topics') { - // Every topic, uncapped: this tab is where the hero's "See all" leads. - return ; + // 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') { 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..3090c2844e --- /dev/null +++ b/apps/web/core/claims/browse/claim-topics-tab.test.tsx @@ -0,0 +1,187 @@ +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 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..184631030b --- /dev/null +++ b/apps/web/core/claims/browse/claim-topics-tab.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; + +import { toExploreFeedItem } from '~/core/explore/explore-card-item'; +import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels'; +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 { RecordLoadError } from '~/partials/profile/partial-load-error'; + +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: claims + news stories + debates, 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. + * + * 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. + */ +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 ordered = React.useMemo(() => { + const byTopicId = counts.countsByTopicId; + const withCounts = rows.data.map(row => ({ row, counts: byTopicId?.[normId(row.entityId)] ?? null })); + if (!byTopicId) return withCounts; + + return withCounts.sort( + (a, b) => + (b.counts?.total ?? 0) - (a.counts?.total ?? 0) || + (b.counts?.claims ?? 0) - (a.counts?.claims ?? 0) || + a.row.title.localeCompare(b.row.title) + ); + }, [counts.countsByTopicId, rows.data]); + + const rowSpaceIds = React.useMemo(() => [...new Set(ordered.map(entry => entry.row.spaceId))], [ordered]); + const { labelsById } = useSpaceLabels(rowSpaceIds); + + // 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.

; + } + + if (rows.isLoading || counts.isLoading) { + return

Loading topics…

; + } + + if (rows.isError && ordered.length === 0) { + return ; + } + + if (ordered.length === 0) { + return

No linked topics could be displayed.

; + } + + return ( +
+ {ordered.map(entry => ( + + ))} +
+ ); +} 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..447196218d --- /dev/null +++ b/apps/web/core/topics/browse/use-topic-connection-counts.test.ts @@ -0,0 +1,76 @@ +import { print } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { normId } from '~/core/utils/norm-id'; + +import { decodeTopicConnectionCounts, topicConnectionCountsDocument } 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 }); + }); +}); 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..2b011e1cd7 --- /dev/null +++ b/apps/web/core/topics/browse/use-topic-connection-counts.ts @@ -0,0 +1,171 @@ +'use client'; + +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { useQuery } 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 for the whole list: each topic contributes three aliased counts over the same + * connections. 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; +} + +/** + * 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[]) { + // Deduped and sorted, so the same set of topics in a different order is the same cache entry and + // not a second request. + const ids = React.useMemo(() => [...new Set(topicIds.map(normId))].sort(), [topicIds]); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['topic', 'connection-counts', ids], + enabled: ids.length > 0, + queryFn: ({ signal }) => + 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, + }); + + return { + countsByTopicId: data ?? null, + isLoading: ids.length > 0 && isLoading, + /** + * A failed count is not an empty one. The tab still lists its topics — it falls back to the + * order the claim carries them in and leaves the metadata row out, rather than printing zeros + * for numbers nobody measured. + */ + isError, + }; +} diff --git a/apps/web/partials/entity-page/relation-chip-section.tsx b/apps/web/partials/entity-page/relation-chip-section.tsx index 92c7e1ccfd..e01dcd93d9 100644 --- a/apps/web/partials/entity-page/relation-chip-section.tsx +++ b/apps/web/partials/entity-page/relation-chip-section.tsx @@ -44,14 +44,11 @@ export function RelationChipSection({ label, relations, spaceId, - cap = CHIP_CAP, }: { /** Also the section's accessible name, so the two can never disagree. */ label: string; relations: Relation[]; spaceId: string; - /** How many chips show before the `+N`. `Infinity` for a surface that is already the full list. */ - cap?: number; }) { const [expanded, setExpanded] = React.useState(false); const chipsRef = React.useRef(null); @@ -82,12 +79,12 @@ export function RelationChipSection({ React.useEffect(() => { if (!expanded || !focusAfterExpandRef.current) return; focusAfterExpandRef.current = false; - chipsRef.current?.querySelectorAll('a')[cap]?.focus(); - }, [cap, expanded]); + chipsRef.current?.querySelectorAll('a')[CHIP_CAP]?.focus(); + }, [expanded]); if (relations.length === 0) return null; - const visible = expanded ? relations : relations.slice(0, cap); + const visible = expanded ? relations : relations.slice(0, CHIP_CAP); const hidden = relations.length - visible.length; return ( diff --git a/apps/web/partials/explore/explore-feed-card.tsx b/apps/web/partials/explore/explore-feed-card.tsx index e3a495a579..d44ae4f75c 100644 --- a/apps/web/partials/explore/explore-feed-card.tsx +++ b/apps/web/partials/explore/explore-feed-card.tsx @@ -21,7 +21,7 @@ import { type ClaimCardVariant, ClaimExploreFeedCard } from './claim-explore-fee import { DebateExploreFeedCard } from './debate-explore-feed-card'; import { DebateExploreMetaRow } from './debate-explore-meta-row'; import { ExploreCardTitle } from './explore-card-title'; -import { ExploreCommentsIcon } from './explore-comments-icon'; +import { ExploreFeedCommentLink } from './explore-feed-comment-link'; import { ExploreMetaRow } from './explore-meta-row'; import { RankingCardBody } from './explore-ranking-card-body'; @@ -50,15 +50,6 @@ type ExploreFeedCardProps = { 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); diff --git a/apps/web/partials/explore/explore-feed-comment-link.tsx b/apps/web/partials/explore/explore-feed-comment-link.tsx new file mode 100644 index 0000000000..1974e1ea68 --- /dev/null +++ b/apps/web/partials/explore/explore-feed-comment-link.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; + +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; + +import { ExploreCommentsIcon } from './explore-comments-icon'; + +/** + * The comment count beside an explore card's vote buttons. + * + * Its own module because the topic card draws the same link as the generic one, and importing it + * from `explore-feed-card` would be a cycle the moment that dispatcher ever draws a topic. + */ +export function ExploreFeedCommentLink({ href, count }: { href: string; count: number }) { + return ( + + + {count} + + ); +} 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..e65a5e68dc --- /dev/null +++ b/apps/web/partials/explore/topic-explore-feed-card.test.tsx @@ -0,0 +1,128 @@ +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', () => ({ + ExploreMetaRow: (props: Record) => ( +
+ ), +})); + +vi.mock('./explore-card-title', () => ({ + ExploreCardTitle: (props: { item: ExploreFeedItem; opensSidePanel: boolean }) => ( +

+ {props.item.title} +

+ ), +})); + +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => ( + + {children} + + ), +})); + +vi.mock('~/partials/entity-page/entity-row-actions', () => ({ + EntityRowActions: (props: { children?: React.ReactNode }) =>
{props.children}
, +})); + +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('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('says so once when a topic has nothing attached', () => { + render(); + + expect(screen.getByText('Nothing attached yet')).toBeInTheDocument(); + }); + + 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..5feb8b3693 --- /dev/null +++ b/apps/web/partials/explore/topic-explore-feed-card.tsx @@ -0,0 +1,136 @@ +'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 { NavUtils } from '~/core/utils/utils'; + +import { FallbackImage } from '~/design-system/fallback-image'; +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; +import { Skeleton } from '~/design-system/skeleton'; + +import { EntityRowActions } from '~/partials/entity-page/entity-row-actions'; + +import { ExploreCardTitle } from './explore-card-title'; +import { ExploreFeedCommentLink } from './explore-feed-comment-link'; +import { ExploreMetaRow } 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 deliberately the generic card's layout down to the class: the same meta row, the same + * 60px thumbnail well, the same title and two-line description, the same actions row. Everything + * new is one line of counts between the description and the actions. + * + * 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, + countsPending = false, + 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; + /** The counts are still in flight, so the line reserves its own height instead of appearing late. */ + countsPending?: boolean; + hideSpaceLink?: boolean; + hideJoinButton?: boolean; + titleOpensSidePanel?: boolean; +}) { + const entityHref = `${NavUtils.toEntity(item.spaceId, item.entityId)}#entity-comments`; + + return ( +
+ + +
+ {item.imageUrl ? ( + + + + ) : null} + +
+
+ + {item.description ? ( +

+ {item.description} +

+ ) : null} +
+ + + + + + +
+
+
+ ); +} + +const SEGMENT_CLASS = 'text-[14px] leading-[13px] font-normal tracking-[-0.35px] text-grey-04'; + +/** "117 claims · 2 news stories · 3 debates", in the meta row's own type and separator. */ +export function TopicConnectionMeta({ + counts, + pending = false, + className, +}: { + counts: TopicConnectionCounts | null; + pending?: boolean; + className?: string; +}) { + // A skeleton the width of a short line, so the card does not grow a row under the reader when + // the counts land a beat after the topic itself. + if (pending) return ; + if (!counts) return null; + + const segments = [ + { key: 'claims', count: counts.claims, noun: ['claim', 'claims'] as const }, + { key: 'news', count: counts.news, noun: ['news story', 'news stories'] as const }, + { key: 'debates', count: counts.debates, noun: ['debate', 'debates'] 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 nothing attached says so once. + if (segments.length === 0) { + return

Nothing attached yet

; + } + + return ( +

+ {segments.map((segment, index) => ( + + {index > 0 ? : null} + + {segment.count.toLocaleString()}{' '} + {segment.count === 1 ? segment.noun[0] : segment.noun[1]} + + + ))} +

+ ); +} From f99888e2258edfa91680f8f6c3e8cdd35299dc9f Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 22 Sep 2026 10:29:11 -0700 Subject: [PATCH 06/11] fix(claims): lead a topic's metadata with its debates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debates, then claims, then news stories — rarest and most specific to least. A topic with debates on it is the interesting case, and the debate count is the one that most often tells two topics apart; trailing it meant it was also the segment most likely to be pushed onto a second line on a narrow card. The ordering of the list itself is unchanged: it still sorts on the total, and still breaks a tie on claims. --- .../explore/topic-explore-feed-card.test.tsx | 13 +++++++++++++ .../partials/explore/topic-explore-feed-card.tsx | 7 +++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/partials/explore/topic-explore-feed-card.test.tsx b/apps/web/partials/explore/topic-explore-feed-card.test.tsx index e65a5e68dc..d88ed81cf1 100644 --- a/apps/web/partials/explore/topic-explore-feed-card.test.tsx +++ b/apps/web/partials/explore/topic-explore-feed-card.test.tsx @@ -68,6 +68,19 @@ describe('TopicExploreFeedCard', () => { 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(); diff --git a/apps/web/partials/explore/topic-explore-feed-card.tsx b/apps/web/partials/explore/topic-explore-feed-card.tsx index 5feb8b3693..5e7b901abd 100644 --- a/apps/web/partials/explore/topic-explore-feed-card.tsx +++ b/apps/web/partials/explore/topic-explore-feed-card.tsx @@ -93,7 +93,7 @@ export function TopicExploreFeedCard({ const SEGMENT_CLASS = 'text-[14px] leading-[13px] font-normal tracking-[-0.35px] text-grey-04'; -/** "117 claims · 2 news stories · 3 debates", in the meta row's own type and separator. */ +/** "3 debates · 117 claims · 2 news stories", in the meta row's own type and separator. */ export function TopicConnectionMeta({ counts, pending = false, @@ -108,10 +108,13 @@ export function TopicConnectionMeta({ if (pending) return ; 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 }, - { key: 'debates', count: counts.debates, noun: ['debate', 'debates'] as const }, ].filter(segment => segment.count > 0); // Zeros are dropped rather than printed: "0 news stories" on a topic that is entirely claims is From b56563b6af0e01554b33eeb7179a1cd8d8b13740 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 22 Sep 2026 11:30:36 -0700 Subject: [PATCH 07/11] refactor(explore): share the card chrome the topic card was copying, and bound the counts request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Three things the first pass got wrong on reuse, and two on robustness. **The topic card was a copy of the generic body.** Twenty-five lines of thumbnail well, title and description, reproduced class for class from `explore-feed-card`'s `DefaultCardBody` — the third copy of that layout in this directory, and the first two drifted (the claim card's copy of the meta row lost `font-normal` on three segments and ran eight pixels taller than the cards either side of it, with nothing in either diff to show it). `explore-card-chrome.tsx` now holds the frame, the body and the actions row. The body takes an optional `meta` slot, which is the whole of what a topic card adds. Its own module rather than an export from `explore-feed-card`, for the reason `ExploreCardTitle` is: reaching back into the dispatcher would close a cycle the moment it ever draws a topic. **The metadata line was spelling out the meta row's type.** `SEGMENT_CLASS` is now exported as `META_SEGMENT_CLASS` and imported, which is exactly the drift the file's own docstring warns about. **The tab was re-implementing the feed shell.** `PersonRecordFeed` already does the space lookup, the item projection and the loading, empty, error and partial-failure states, and the Sources tab already uses it for the same shape of question — a bounded id list hydrated into explore cards. It now takes an optional `renderCard`, and the Topics tab passes the topic card. The key stays inside the feed so a caller cannot forget it. **The counts request was unbounded.** Every topic added three aliased counts to one query, one of them a two-hop nested filter, and a failure lost every topic's numbers. Batched at ten, the way `useClaimExploreRows` and `fetchExploreRowsByIds` batch ids. Claims measured on testnet carry at most 7 topics and three at the 95th percentile, so this stays one request in practice; what it buys is that a pathological claim cannot become one huge query, and a batch that fails costs only its own topics their metadata line. **`countsPending` was unreachable.** The tab gates on the counts before it renders anything, so the height-reserving skeleton the prop described could never draw. Removed rather than wired up. No visual change: checked against testnet that Explore's News story card — the one that draws the extracted body — and the Topics tab are both unchanged. --- .../core/claims/browse/claim-topics-tab.tsx | 67 +++++------ .../use-topic-connection-counts.test.ts | 38 ++++++- .../browse/use-topic-connection-counts.ts | 105 ++++++++++++------ .../partials/explore/explore-card-chrome.tsx | 100 +++++++++++++++++ .../partials/explore/explore-feed-card.tsx | 58 +--------- .../web/partials/explore/explore-meta-row.tsx | 13 ++- .../explore/topic-explore-feed-card.test.tsx | 1 + .../explore/topic-explore-feed-card.tsx | 72 +++--------- .../profile/person-record-feed.test.tsx | 18 +++ .../partials/profile/person-record-feed.tsx | 67 ++++++----- 10 files changed, 333 insertions(+), 206 deletions(-) create mode 100644 apps/web/partials/explore/explore-card-chrome.tsx diff --git a/apps/web/core/claims/browse/claim-topics-tab.tsx b/apps/web/core/claims/browse/claim-topics-tab.tsx index 184631030b..f92793fa03 100644 --- a/apps/web/core/claims/browse/claim-topics-tab.tsx +++ b/apps/web/core/claims/browse/claim-topics-tab.tsx @@ -2,14 +2,12 @@ import * as React from 'react'; -import { toExploreFeedItem } from '~/core/explore/explore-card-item'; -import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels'; 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 { RecordLoadError } from '~/partials/profile/partial-load-error'; +import { PersonRecordFeed } from '~/partials/profile/person-record-feed'; import { useClaimExploreRows } from './use-claim-explore-rows'; @@ -30,6 +28,10 @@ import { useClaimExploreRows } from './use-claim-explore-rows'; * 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. + * + * 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. @@ -50,21 +52,19 @@ export function ClaimTopicsTab({ topics, spaceId }: { topics: Relation[]; spaceI // 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 ordered = React.useMemo(() => { - const byTopicId = counts.countsByTopicId; - const withCounts = rows.data.map(row => ({ row, counts: byTopicId?.[normId(row.entityId)] ?? null })); - if (!byTopicId) return withCounts; + if (!countsByTopicId) return rows.data; + + const totalOf = (entityId: string) => countsByTopicId[normId(entityId)]; - return withCounts.sort( + return [...rows.data].sort( (a, b) => - (b.counts?.total ?? 0) - (a.counts?.total ?? 0) || - (b.counts?.claims ?? 0) - (a.counts?.claims ?? 0) || - a.row.title.localeCompare(b.row.title) + (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) ); - }, [counts.countsByTopicId, rows.data]); - - const rowSpaceIds = React.useMemo(() => [...new Set(ordered.map(entry => entry.row.spaceId))], [ordered]); - const { labelsById } = useSpaceLabels(rowSpaceIds); + }, [countsByTopicId, 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. @@ -72,30 +72,31 @@ export function ClaimTopicsTab({ topics, spaceId }: { topics: Relation[]; spaceI return

No topics have been linked to this claim yet.

; } - if (rows.isLoading || counts.isLoading) { - return

Loading topics…

; - } - - if (rows.isError && ordered.length === 0) { - return ; - } - - if (ordered.length === 0) { - return

No linked topics could be displayed.

; - } - return ( -
- {ordered.map(entry => ( + ( - ))} -
+ )} + /> ); } 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 index 447196218d..db67e5aa9d 100644 --- a/apps/web/core/topics/browse/use-topic-connection-counts.test.ts +++ b/apps/web/core/topics/browse/use-topic-connection-counts.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it } from 'vitest'; import { normId } from '~/core/utils/norm-id'; -import { decodeTopicConnectionCounts, topicConnectionCountsDocument } from './use-topic-connection-counts'; +import { + TOPIC_COUNT_BATCH_SIZE, + decodeTopicConnectionCounts, + topicConnectionCountsDocument, + topicCountBatches, +} from './use-topic-connection-counts'; describe('topicConnectionCountsDocument', () => { it('declares one variable and three buckets per topic', () => { @@ -74,3 +79,34 @@ describe('decodeTopicConnectionCounts', () => { 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 index 2b011e1cd7..d836741bac 100644 --- a/apps/web/core/topics/browse/use-topic-connection-counts.ts +++ b/apps/web/core/topics/browse/use-topic-connection-counts.ts @@ -1,7 +1,7 @@ 'use client'; import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { useQuery } from '@tanstack/react-query'; +import { useQueries } from '@tanstack/react-query'; import * as React from 'react'; @@ -125,47 +125,84 @@ export function decodeTopicConnectionCounts( } /** - * Claims, news stories and debates attached to each of these topics. + * Topics per request. * - * 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. + * 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 function useTopicConnectionCounts(topicIds: string[]) { - // Deduped and sorted, so the same set of topics in a different order is the same cache entry and - // not a second request. - const ids = React.useMemo(() => [...new Set(topicIds.map(normId))].sort(), [topicIds]); - - const { data, isLoading, isError } = useQuery({ - queryKey: ['topic', 'connection-counts', ids], - enabled: ids.length > 0, - queryFn: ({ signal }) => - 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, - }); +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: data ?? null, - isLoading: ids.length > 0 && isLoading, + 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 — it falls back to the * order the claim carries them in and leaves the metadata row out, rather than printing zeros * for numbers nobody measured. */ - isError, + 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/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 d44ae4f75c..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 { ExploreFeedCommentLink } from './explore-feed-comment-link'; import { ExploreMetaRow } from './explore-meta-row'; import { RankingCardBody } from './explore-ranking-card-body'; @@ -58,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({ @@ -179,15 +136,10 @@ 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 ? ( ) : ( @@ -199,7 +151,7 @@ function BaseExploreFeedCard({ ) : isRanking ? ( ) : ( - + {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 index d88ed81cf1..0c84b56785 100644 --- a/apps/web/partials/explore/topic-explore-feed-card.test.tsx +++ b/apps/web/partials/explore/topic-explore-feed-card.test.tsx @@ -12,6 +12,7 @@ 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) => (
), diff --git a/apps/web/partials/explore/topic-explore-feed-card.tsx b/apps/web/partials/explore/topic-explore-feed-card.tsx index 5e7b901abd..5a0e882197 100644 --- a/apps/web/partials/explore/topic-explore-feed-card.tsx +++ b/apps/web/partials/explore/topic-explore-feed-card.tsx @@ -6,17 +6,9 @@ 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 { NavUtils } from '~/core/utils/utils'; -import { FallbackImage } from '~/design-system/fallback-image'; -import { PrefetchLink as Link } from '~/design-system/prefetch-link'; -import { Skeleton } from '~/design-system/skeleton'; - -import { EntityRowActions } from '~/partials/entity-page/entity-row-actions'; - -import { ExploreCardTitle } from './explore-card-title'; -import { ExploreFeedCommentLink } from './explore-feed-comment-link'; -import { ExploreMetaRow } from './explore-meta-row'; +import { EXPLORE_CARD_CLASS, ExploreCardActions, ExploreCardDefaultBody } from './explore-card-chrome'; +import { ExploreMetaRow, META_SEGMENT_CLASS } from './explore-meta-row'; import { MetaDot } from './meta-dot'; /** @@ -25,9 +17,11 @@ import { MetaDot } from './meta-dot'; * 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 deliberately the generic card's layout down to the class: the same meta row, the same - * 60px thumbnail well, the same title and two-line description, the same actions row. Everything - * new is one line of counts between the description and the actions. + * + * 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 @@ -37,7 +31,6 @@ import { MetaDot } from './meta-dot'; export function TopicExploreFeedCard({ item, counts, - countsPending = false, hideSpaceLink = false, hideJoinButton = false, titleOpensSidePanel = false, @@ -48,64 +41,31 @@ export function TopicExploreFeedCard({ * metadata line at all rather than a row of zeros. */ counts: TopicConnectionCounts | null; - /** The counts are still in flight, so the line reserves its own height instead of appearing late. */ - countsPending?: boolean; hideSpaceLink?: boolean; hideJoinButton?: boolean; titleOpensSidePanel?: boolean; }) { - const entityHref = `${NavUtils.toEntity(item.spaceId, item.entityId)}#entity-comments`; - return ( -
+
- -
- {item.imageUrl ? ( - - - - ) : null} - -
-
- - {item.description ? ( -

- {item.description} -

- ) : null} -
- - - - - - -
-
+ } + actions={} + />
); } -const SEGMENT_CLASS = 'text-[14px] leading-[13px] font-normal tracking-[-0.35px] text-grey-04'; - /** "3 debates · 117 claims · 2 news stories", in the meta row's own type and separator. */ export function TopicConnectionMeta({ counts, - pending = false, className, }: { counts: TopicConnectionCounts | null; - pending?: boolean; className?: string; }) { - // A skeleton the width of a short line, so the card does not grow a row under the reader when - // the counts land a beat after the topic itself. - if (pending) return ; if (!counts) return null; // Debates first, then claims, then news stories: rarest and most specific to least. A topic with @@ -120,11 +80,11 @@ export function TopicConnectionMeta({ // 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 nothing attached says so once. if (segments.length === 0) { - return

Nothing attached yet

; + return

Nothing attached yet

; } return ( -

+

{segments.map((segment, index) => ( {index > 0 ? : null} 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 53beb5b8ea..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'; @@ -42,6 +42,7 @@ export function PersonRecordFeed({ responseByClaimId, personName, fullWidthDebates = false, + renderCard, }: { rows: ExploreFeedRow[]; isLoading: boolean; @@ -85,6 +86,17 @@ export function PersonRecordFeed({ 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. @@ -131,31 +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 + /> + )} + ))}
From ae59fe79e48c84ae546e8a4e500f1c7833b4579b Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 22 Sep 2026 11:48:59 -0700 Subject: [PATCH 08/11] fix(claims): stop the hero shifting, and keep a full-width debate fitting the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review of this branch, all in the claim hero and the debate card it brought in. **The hero re-wrapped its own title on every load.** `hasVerdict` cannot be true until the response counts answer, and the grid template was derived from it, so every claim painted one-column and then reflowed to `1fr 220px` when the column appeared — a layout shift at the very top of the page that the old below-header verdict never had. The template now follows a separate question: keep both tracks while the counts are out, and give the second one back only on a settled zero. An answered claim is the common case here, so the unknown state reserves the column. The column's *contents* still wait for real numbers. **An empty grid row sat 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 the controversial/topics strip — which draws for almost nothing, since `SHOW_HERO_TOPICS` is off. With it empty the rows still started at 2, so `claim-card-narrow:gap-y-4` left 16px above the claim belonging to a row nothing occupied. Visible in the side panel and at phone widths, where that gap is set. The rows now move up when there is nothing to draw there. Measured 16px → 0px at 420px. **A full-width debate stopped fitting the screen.** `fullWidth` dropped the `max-w` *and* the viewport-height budget, but it only ever meant "don't cap me at the card's 560px, this column is already the reading width". On the claim page's 840px column that made a debate 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 one thing the budget exists to guarantee. `fullWidth` now raises the ceiling to the container rather than removing it: 800px at a 1300px viewport, 569px at 900px, 403px at 700px, against a flat 800px before. Checked in a browser against testnet: the claim title measures 73px at first paint and 73px settled, and the dead space above it at 420px is gone. The column arithmetic is measured rather than reasoned about — a logged-out debate card falls back to the generic one, so the video layout itself is still unverified. --- .../claims/browse/claim-page-view.test.tsx | 49 ++++++++++++++++++- .../core/claims/browse/claim-page-view.tsx | 35 +++++++++++-- .../explore/debate-explore-feed-card.tsx | 31 +++++++++--- 3 files changed, 101 insertions(+), 14 deletions(-) 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 42eb69552a..54d713782d 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -15,6 +15,10 @@ 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, + /** 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. */ @@ -115,10 +119,10 @@ vi.mock('./use-claim-response-state', () => ({ useClaimResponseState: () => ({ responseKind: 'stance', summary: { - isLoading: false, + isLoading: mocks.summaryLoading, hasCounts: true, total: mocks.responseTotal, - isControversial: false, + isControversial: mocks.isControversial, viewerDirection: 'positive', viewerSpaceId: 'viewer-space', isViewerResponseLoading: true, @@ -213,6 +217,8 @@ function claimEntity(description: string | null) { beforeEach(() => { mocks.responseTotal = 11; + mocks.summaryLoading = false; + mocks.isControversial = false; mocks.entity = claimEntity('A description long enough that the page has something to collapse.'); mocks.clamp = null; mocks.chipSection = null; @@ -320,6 +326,45 @@ describe('ClaimPageView record', () => { 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('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('orders Overview as activity, then comments', () => { render(); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 1c5d272ac3..18a31d91d8 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -137,11 +137,25 @@ export function ClaimPageView({ 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. + const reserveVerdictColumn = summary.isLoading || hasVerdict; const topics = React.useMemo(() => relationsOfType(entity?.relations, TOPICS_PROPERTY_ID), [entity?.relations]); const topicIds = React.useMemo(() => topics.map(topic => topic.toEntity.id), [topics]); const sources = React.useMemo(() => getClaimSources(entity?.relations ?? []), [entity?.relations]); + // 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, authoredTabId: activeAuthoredTabId, @@ -209,14 +223,14 @@ export function ClaimPageView({
{/* 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. */} - {(SHOW_HERO_TOPICS && topics.length > 0) || summary.isControversial ? ( + {hasChipsRow ? ( ) : 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 @@ -263,7 +279,9 @@ export function ClaimPageView({ )}
-
+
@@ -271,7 +289,14 @@ export function ClaimPageView({ than the chips above it; the rule runs beside the claim and the pills. Explore's own column, not a copy of it. */} {hasVerdict ? ( -
+
{/* 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 From 15c8bafff5d801254a6a9129009c54996bb298e9 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 22 Sep 2026 12:21:07 -0700 Subject: [PATCH 09/11] fix(claims): don't announce a Topics landmark over a row with no links, and don't order on a partial count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from Copilot's review, all confirmed against the code. **An empty navigation landmark.** `ClaimTopicsRow` was always a `