From e01bbbb8fe681112413fa19b2507cff1ecd3b1a3 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 1/4] 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 | 153 ++++++------ .../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 ++++- .../debates/browse/debate-feed-player.tsx | 10 +- apps/web/design-system/button.tsx | 22 +- .../explore/debate-explore-feed-card.tsx | 13 +- .../partials/explore/explore-feed-card.tsx | 10 +- .../partials/profile/person-record-feed.tsx | 4 + .../profile/profile-activity-section.test.tsx | 47 ++-- .../profile/profile-activity-section.tsx | 221 +++++++++--------- .../partials/space-page/space-member-row.tsx | 7 +- 15 files changed, 417 insertions(+), 256 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 cc70698fc5..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'; @@ -155,9 +154,7 @@ export function ClaimPageView({ }; // Matches profile record tabs: unknown/error stays reachable, while a settled zero disappears. const hasDebates = hasRecordToShow( - record.debatesLoading || record.debatesError || record.debatesCountUnavailable - ? undefined - : record.debatesTotal + record.debatesLoading || record.debatesError || record.debatesCountUnavailable ? undefined : record.debatesTotal ); const hasClaims = hasRecordToShow( record.claimsLoading || record.claimsError || record.claimsCountUnavailable ? undefined : record.claimsTotal @@ -188,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 @@ -212,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[]; @@ -347,7 +360,7 @@ function ClaimTabPanel({ isError: record.debatesError, isCountUnavailable: record.debatesCountUnavailable, href: hrefs.debates, - seeAllLabel: 'See all debates', + seeAllLabel: 'View all debates', onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('debates') : undefined, }, { @@ -359,19 +372,13 @@ function ClaimTabPanel({ isError: record.claimsError, isCountUnavailable: record.claimsCountUnavailable, href: hrefs.claims, - seeAllLabel: 'See all claims', + seeAllLabel: 'View all claims', onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('claims') : undefined, }, ]; 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 @@ -395,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. @@ -440,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/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 143aa60370..f5034b82c7 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -36,12 +36,7 @@ type DebateFeedPlayerProps = { preload?: boolean; }; -export function DebateFeedPlayer({ - debate, - active, - preload = false, - reducedOverlays = false, -}: DebateFeedPlayerProps) { +export function DebateFeedPlayer({ debate, active, preload = false, reducedOverlays = false }: DebateFeedPlayerProps) { // Loading is deliberately wider than playing. `useDebatePlayback`'s flag gates only the URL // fetch and the transcript query — playback is driven by `active` in the effect below — so a // preloading card fetches without autoplaying off-screen. @@ -324,7 +319,8 @@ export function DebateFeedPlayer({ data-debate-autoplay-blocked={autoplayBlocked ? 'true' : 'false'} // No gap and one radius on the outside: the two tiles are a single surface in the Figma // frame, which is what lets the subtitle straddle the seam rather than sit inside one tile. - className="group relative flex flex-col overflow-hidden rounded-xl" + // 12px in the compact gallery (a profile's or claim's Activity), 16px in the feeds. + className={cx('group relative flex flex-col overflow-hidden', reducedOverlays ? 'rounded-lg' : 'rounded-xl')} > & { small?: boolean; }; -const buttonClassNames = (className = '') => +/** The pill's shape and type, shared by both of its colourings below. */ +const PILL_BUTTON_SHAPE = + 'h-7 shrink-0 !gap-0 !rounded-full !px-2.5 !py-0 !text-[16px] !leading-[13px] font-normal tracking-[-0.35px] whitespace-nowrap !shadow-none'; + +/** + * The black pill: the navbar's Log in button. + * Layered over the `primary` variant, whose colours it overrides. + */ +export const PILL_BUTTON_CLASS_NAME = `${PILL_BUTTON_SHAPE} !border-transparent !bg-[#151515] !text-white hover:!bg-[#151515] focus-visible:!border-text focus-visible:!shadow-inner-text`; + +/** + * The same pill in the `secondary` variant's colours — white, a grey outline that + * darkens on hover. Layered over `secondary`, which supplies those colours. + */ +export const PILL_BUTTON_SECONDARY_CLASS_NAME = PILL_BUTTON_SHAPE; + +/** + * The button's classes on their own, for a link that should look like one. A + * ` - ); - })} + + + ); + })} + + {/* + * The row's own controls, right of the tabs: step through the cards, then leave for the + * full tab. Both belong to the selected kind — see `navigation`. + */} +
+ {navigation && (navigation.left || navigation.right) ? ( + // The data block galleries' own arrows (`table-pagination`), 12px apart as they are + // there: dark when there is somewhere to go, grey at the row's end. + + navigation.scrollByCard(-1)} /> + navigation.scrollByCard(1)} /> + + ) : null} +
- )} +
{selected.isError && selected.rows.length === 0 ? ( @@ -177,7 +194,7 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { * links to holds the authoritative list and offers the retry, so a * second control here would be a second thing to keep in step. */ -

Couldn’t load {selected.label.toLowerCase()}.

+

Couldn’t load {selected.label.toLowerCase()}.

) : ( )} - {/* @@ -210,19 +227,15 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { function ProfileActivitySkeleton() { return ( -
-
-

Activity

- +
+
+

Activity

+
+ + +
-
+
@@ -233,17 +246,12 @@ function ProfileActivitySkeleton() { } function ActivitySeeAll({ kind }: { kind: ActivityKind }) { - const content = ( - <> - {kind.seeAllLabel} - - - ); + const className = buttonClassNames(PILL_BUTTON_SECONDARY_CLASS_NAME)({ variant: 'secondary' }); if (kind.onSeeAll) { return ( - ); } @@ -252,8 +260,8 @@ function ActivitySeeAll({ kind }: { kind: ActivityKind }) { // chrome. The fragment puts the tab row under the navbar instead. Side panels use `onSeeAll` // above because their tabs are selected in place and have no route fragment to follow. return ( - - {content} + + {kind.seeAllLabel} ); } @@ -440,14 +448,19 @@ function useMobileActivityHeightReserve(selectedKey: string | undefined) { return { sectionRef, reserveRef, prepareSwitch }; } +type GalleryNavigation = { left: boolean; right: boolean; scrollByCard: (direction: -1 | 1) => void }; + function ActivityGallery({ rows, responseByClaimId, personName, + onNavigationChange, }: { rows: ExploreFeedRow[]; responseByClaimId?: Record; personName?: string | null; + /** Where the header's arrows learn whether this row can move, and how to move it. */ + onNavigationChange?: (navigation: GalleryNavigation | null) => void; }) { const shown = React.useMemo(() => rows.slice(0, ACTIVITY_GALLERY_CARD_LIMIT), [rows]); @@ -466,6 +479,14 @@ function ActivityGallery({ scrollByCard, } = useActivityGallery(shown); + React.useEffect(() => { + onNavigationChange?.({ left: canScrollLeft, right: canScrollRight, scrollByCard }); + }, [canScrollLeft, canScrollRight, scrollByCard, onNavigationChange]); + + // Unmounted on a tab switch (it is keyed by kind) and when a kind fails: its row is gone, so the + // header should not offer to scroll it. + React.useEffect(() => () => onNavigationChange?.(null), [onNavigationChange]); + return ( // One at a time. Compact cards can leave several debates fully visible, so intersection alone // cannot choose. The first debate receives autoplay; clicking another player transfers the @@ -490,16 +511,14 @@ function ActivityGallery({ {/* * `snap-x` so a flick lands on a card rather than between two. * - * The gap at either end is a spacer element rather than padding on the scroller: a scroll - * container's trailing padding is dropped by every browser that matters, so `p-4` gave 16px - * on the left and nothing on the right. Spacers are honoured on both sides, and `scroll-px` - * keeps a snapped card off the edge it lands against. + * No inset at the start: the first card sits flush with the column, under the heading. On a + * phone the row bleeds to the screen edge, so a trailing spacer — not padding, which a + * scroll container drops at its far end — keeps the last card off it. */}
- {shown.map(row => ( ))} - +
- {canScrollLeft ? scrollByCard(-1)} /> : null} - {canScrollRight ? scrollByCard(1)} /> : null} + {/* + * A 40px white fade over the right edge while there is more to scroll: enough to say the + * row goes on, narrow enough to leave the cut-off card readable. It sits on the wrapper, + * not the scroller, so it stays put while the cards move under it, and it never takes a + * click meant for the card beneath. + */} + {canScrollRight ? ( +
+ ) : null}
); @@ -662,12 +692,9 @@ function useActivityGallery(rows: ExploreFeedRow[]) { [scrollerRef] ); - const requestPlayback = React.useCallback( - (debateId: string) => { - if (availableRef.current.has(normId(debateId))) setSelectedPlaybackId(debateId); - }, - [] - ); + const requestPlayback = React.useCallback((debateId: string) => { + if (availableRef.current.has(normId(debateId))) setSelectedPlaybackId(debateId); + }, []); const setPlaybackAvailable = React.useCallback((debateId: string, available: boolean) => { const id = normId(debateId); @@ -697,24 +724,6 @@ function useActivityGallery(rows: ExploreFeedRow[]) { }; } -function GalleryNavigationButton({ direction, onClick }: { direction: 'left' | 'right'; onClick: () => void }) { - return ( - - ); -} - /** * One card in the row. * diff --git a/apps/web/partials/space-page/space-member-row.tsx b/apps/web/partials/space-page/space-member-row.tsx index e2dc3d763f..25b01515d6 100644 --- a/apps/web/partials/space-page/space-member-row.tsx +++ b/apps/web/partials/space-page/space-member-row.tsx @@ -1,3 +1,5 @@ +import cx from 'classnames'; + import { OmitStrict, Profile } from '~/core/types'; import { formatShortAddress } from '~/core/utils/utils'; @@ -6,11 +8,12 @@ import { PrefetchLink as Link } from '~/design-system/prefetch-link'; interface EditorRowProps { user: OmitStrict; + className?: string; } -export function MemberRow({ user }: EditorRowProps) { +export function MemberRow({ user, className }: EditorRowProps) { return ( - +
From 9642848ee4d0c4497bcf0a5fc0f29f2c56aabc86 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 2/4] 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 60fbf3086aff0a923b2890565f4df4ac0a86e1f3 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 3/4] 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 175549b08a693dd02cba8487f42f73ede35edb44 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 4/4] 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. */}