diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx index 869a5707b8..31eabac1a3 100644 --- a/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx +++ b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx @@ -14,7 +14,7 @@ export type ClaimRecordPageProps = { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; -/** The shared server guard and page shell behind the claim's three record routes. */ +/** The shared server guard and page shell behind the claim's record routes. */ export async function ClaimRecordPage(props: ClaimRecordPageProps) { const params = await props.params; const searchParams = await props.searchParams; diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx new file mode 100644 index 0000000000..07ef5185e3 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimTopicsPage(props: ClaimRecordPageProps) { + return ; +} diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 71efb965b3..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,10 +143,20 @@ vi.mock('./claim-comment-position', () => ({ }, })); vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => () => {} })); +// The prefetching link reaches for the sync engine; the topic chips only need to be links. +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => ( + + {children} + + ), +})); vi.mock('~/core/debates/backfill-readiness-for-held-position', () => ({ useBackfillReadinessForHeldPosition: () => {}, })); -vi.mock('./claim-verdict', () => ({ ClaimVerdict: () =>
})); +vi.mock('~/partials/explore/claim-explore-feed-card', () => ({ + ClaimVerdictColumn: () =>
, +})); vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () =>
})); vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null })); vi.mock('./claim-record-tab', () => ({ @@ -185,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; @@ -261,16 +277,44 @@ describe('ClaimPageView record', () => { ]); }); - it('orders Overview as response summary, position, activity, then comments', () => { + it('heads the page like an Explore claim card: the claim, the pills, then the verdict column', () => { + mocks.entity = { + ...mocks.entity, + types: [{ id: 'claim-type', name: 'Claim' }], + relations: [{ id: 'relation-1', type: { id: TOPICS_PROPERTY_ID }, toEntity: { id: 'topic-1', name: 'Ethics' } }], + }; render(); + const heading = screen.getByRole('heading', { level: 1 }); const position = screen.getByTestId('position'); const verdict = screen.getByTestId('verdict'); + const tabs = screen.getByTestId('tabs'); + + // Topics are on their own tab for now, not above the claim. + expect(screen.queryByRole('navigation', { name: 'Topics' })).toBeNull(); + expect(heading.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(position.compareDocumentPosition(verdict) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(verdict.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // No type chip: every claim on this page is a Claim. + expect(screen.queryByText('Claim')).toBeNull(); + }); + + it('draws no verdict column on a claim nobody has answered, as Explore does', () => { + mocks.responseTotal = 0; + render(); + + expect(screen.queryByTestId('verdict')).toBeNull(); + expect(screen.getByTestId('position')).toBeInTheDocument(); + }); + + it('orders Overview as activity, then comments', () => { + render(); + + const tabs = screen.getByTestId('tabs'); const activity = screen.getByTestId('activity'); const comments = screen.getByTestId('comments'); - expect(verdict.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(position.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(tabs.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(activity.compareDocumentPosition(comments) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); @@ -388,38 +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 cc70698fc5..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'; @@ -22,6 +22,8 @@ import type { Relation, TabEntity } from '~/core/types'; import { NavUtils } from '~/core/utils/utils'; import { ClampedText } from '~/design-system/clamped-text'; +import { Fire } from '~/design-system/icons/fire'; +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; import { Text } from '~/design-system/text'; @@ -34,7 +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 { SectionTitle } from '~/partials/entity-page/section-title'; +import { ClaimVerdictColumn } from '~/partials/explore/claim-explore-feed-card'; import { type ActivityKind, ProfileActivitySection } from '~/partials/profile/profile-activity-section'; import { SPACE_TABS_ANCHOR } from '~/partials/space-page/space-tabs-anchor'; @@ -42,16 +44,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 { ControversialTag } from './claim-summary'; -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({ @@ -69,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'; } @@ -76,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'; } @@ -128,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, @@ -151,22 +156,23 @@ 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. 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 ); + 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,73 +194,106 @@ export function ClaimPageView({ className={`mx-auto flex w-full flex-col gap-6 py-6 @[560px]:gap-8 @[560px]:py-8 ${CLAIM_PAGE_CONTENT_INSET_CLASS}`} style={{ maxWidth: CLAIM_PAGE_CONTENT_MAX_WIDTH }} > - {/* Hero */} -
- {/* `text-pretty`, not `text-balance`. Balancing evens every line to the same length, - which on a claim — a full sentence running to three or four lines — leaves each one - breaking well short of the measure and reads as wrapping early. Pretty only avoids a - stranded last word, so the lines fill. */} - {isEditing ? ( - - ) : ( -

- {entity.name ?? entity.id} -

- )} - - {/* Clamped, like entity pages and the side panel (GEO-2772). What is shared is the line - budget, not the cut: wrapping differs with width, so the route, the side panel and a - phone — three widths of one layout, per the note above — break at different words. - They give up the same three lines of vertical space, which a character count could - not do; the same count spends a different number of lines at each width, which is the - measurement the reader actually feels. - - `ClampedText` measures an unclamped clone, so the toggle appears only when something - is genuinely hidden, and it is unaffected by the naive-overflow bug GEO-2756 fixed in - the feed's own title. */} - {isEditing ? ( - + {/* Hero: the Explore claim card's layout at page scale. Everything you can *do* to the + claim on the left — what it is, the claim, the pills — and where opinion stands on the + right, behind a rule that runs the full height. Under `claim-card-narrow` (a 520px + container) it stacks: the verdict drops below the pills and the rule goes, exactly as + the card does. `@container` here so that decision is the hero's width, not the page's. + + No verdict, no column: a claim nobody has answered takes the full width, as on Explore. */} +
+
+ {/* Above the claim and across both columns: what it is about. Capped, with the rest a + tab away rather than a wall of chips over the title. No type or tag chips — every + claim on this page is a Claim. Drawn only when there is something to draw, so a claim + with neither leaves no empty track behind. */} + {(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" + /> + ) : null} + +
+ {/* `text-pretty`, not `text-balance`. Balancing evens every line to the same length, + which on a claim — a full sentence running to three or four lines — leaves each one + breaking well short of the measure and reads as wrapping early. Pretty only avoids a + stranded last word, so the lines fill. */} + {isEditing ? ( + + ) : ( +

+ {entity.name ?? entity.id} +

+ )} + + {/* Clamped, like entity pages and the side panel (GEO-2772). What is shared is the line + budget, not the cut: wrapping differs with width, so the route, the side panel and a + phone break at different words but give up the same three lines of vertical space. + `ClampedText` measures an unclamped clone, so the toggle appears only when something + is genuinely hidden (GEO-2756). */} + {isEditing ? ( + + ) : ( + entity.description && ( + + ) + )} +
+ +
+ +
+ + {/* From the title's row down, so the share lines up with the claim's first line rather + than the chips above it; the rule runs beside the claim and the pills. Explore's own + column, not a copy of it. */} + {hasVerdict ? ( +
+ +
+ ) : null} +
+
+ +
+ tab.label)} + divideBeforeAuthored /> - ) : ( - entity.description && ( - - ) - )} - - {/* What this is. Topics — what it is *about* — used to sit opposite these, pushed to the - right of the same row; they are their own section below now (GEO-2781), so this row - has one job and no longer has to survive being squeezed from both ends in the side - panel. */} -
- {typeName && {typeName}} - {tags.map(tag => ( - {tag.toEntity.name ?? tag.toEntity.id} - ))} - {/* Among the chips that say what this is, which is what "contested" is — and the same - component the cards use, so all three surfaces move together. Not a chip itself: - the flame and red are what make it findable among neutral ones. */} - {summary.isControversial ? : null}
-
- -
- tab.label)} - divideBeforeAuthored - />
; topics: Relation[]; availableSpaceIds: string[]; @@ -333,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 ; } @@ -347,7 +385,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,24 +397,14 @@ 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 - belong to the Overview's supporting context and follow its Activity record. */} - {/* Last, like the ordinary entity page. An empty thread is an invitation, not absence. */} - {/* No readiness switch — the Debate toggle is gone from the product. Master left the header - row that used to hold it; with nothing on its right there is no row, just a label. */} - Your position + // No card of its own: it renders in the hero's left column, under the claim. +
{control.responseError ? (
@@ -483,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-record-tab.tsx b/apps/web/core/claims/browse/claim-record-tab.tsx index 8fc500444e..92c1155948 100644 --- a/apps/web/core/claims/browse/claim-record-tab.tsx +++ b/apps/web/core/claims/browse/claim-record-tab.tsx @@ -39,13 +39,7 @@ type ClaimRecordTabProps = { * difference is the initial space: a claim page opens scoped to the space in its URL, while * clearing Spaces broadens the tab to every space carrying that same claim. */ -export function ClaimRecordTab({ - kind, - claimId, - spaceId, - availableSpaceIds, - sourceTopics, -}: ClaimRecordTabProps) { +export function ClaimRecordTab({ kind, claimId, spaceId, availableSpaceIds, sourceTopics }: ClaimRecordTabProps) { return ( ('best'); const spaces = useRecordSelection([spaceId]); const topics = useRecordSelection(); @@ -196,6 +184,9 @@ function ClaimRecordTabContent({ } errorLabel={isClaims ? 'Couldn’t load claims.' : 'Couldn’t load debates.'} noun={isClaims ? 'claims' : 'debates'} + // The claim page's column is already the reading width; a capped debate sat narrower than + // everything else on the tab. + fullWidthDebates />
); diff --git a/apps/web/core/claims/browse/claim-side-responders.tsx b/apps/web/core/claims/browse/claim-side-responders.tsx index 2c0ba352b4..b54954a80e 100644 --- a/apps/web/core/claims/browse/claim-side-responders.tsx +++ b/apps/web/core/claims/browse/claim-side-responders.tsx @@ -44,7 +44,7 @@ export function ClaimSideResponders({ spaceId: string; responseKind: ResponseKind; direction: ActiveResponseDirection; - /** Names the side in the claim's own vocabulary, for the panel's footer and the trigger's label. */ + /** Names the side in the claim's own vocabulary, for the panel's title and the trigger's label. */ label: string; /** The authoritative count for this side, which can exceed the faces the query returns. */ totalResponders: number; @@ -115,7 +115,7 @@ export function ClaimSideResponders({ /** * The list itself: the space editors and members popover pattern — same scroll cap, same divided * rows, same counted footer — so a reader meets one list shape in the app rather than two that do - * the same job differently. + * the same job differently — except that the count is a title above the rows, not a footer. * * Narrower than those, though. They hang off a page header with the width to spare; this hangs off a * count inside a card, and at 356px it arrived as a slab wider than the column that opened it. A row @@ -128,22 +128,34 @@ function ResponderList({ spaceIds, label, totalCount }: { spaceIds: string[]; la staleTime: 30_000, }); + // People with a profile picture first, so the top of the list is faces rather than generated + // placeholders. Stable within each group, so the order the responders came in otherwise holds. + const sortedProfiles = React.useMemo( + () => (profiles ? [...profiles].sort((a, b) => Number(Boolean(b.avatarUrl)) - Number(Boolean(a.avatarUrl))) : []), + [profiles] + ); + return (
+ {/* A title over the list rather than a counted footer under it: it says what the list is + before the reader scrolls it. The verb agrees with the count — "1 person agrees", + "6 people agree". */} +

+ {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-verdict.test.tsx b/apps/web/core/claims/browse/claim-verdict.test.tsx deleted file mode 100644 index ed2a472cd4..0000000000 --- a/apps/web/core/claims/browse/claim-verdict.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { ClaimResponseSummary } from './claim-response-summary'; -import { summarizeClaimResponses } from './claim-response-summary'; -import { ClaimVerdict } from './claim-verdict'; - -// The sides render their own responder query and popover, neither of which this module decides. -vi.mock('./claim-summary', () => ({ - ClaimSides: () =>
, - ClaimSplitBar: () =>
, -})); - -const ENTITY = 'claim-1'; -const SPACE = 'space-1'; - -/** The hook's shape, so a test says only what it is about. */ -function summary(overrides: Partial = {}): ClaimResponseSummary { - return { - ...summarizeClaimResponses(0, 0), - isLoading: false, - isViewerResponseLoading: false, - hasCounts: true, - viewerDirection: null, - indexedViewerDirection: null, - viewerSpaceId: null, - ...overrides, - }; -} - -function renderVerdict(value: ClaimResponseSummary) { - return render(); -} - -afterEach(cleanup); - -describe('ClaimVerdict', () => { - it('invites a first response on a claim the server says nobody has answered', () => { - renderVerdict(summary()); - - expect(screen.getByText('No responses yet')).toBeInTheDocument(); - }); - - it('reports the share once there are responses', () => { - renderVerdict(summary({ ...summarizeClaimResponses(17, 3) })); - - expect(screen.getByText('85%')).toBeInTheDocument(); - expect(screen.getByText('20 responses')).toBeInTheDocument(); - expect(screen.queryByText('No responses yet')).toBeNull(); - }); - - // `total: 0` has three causes and only one of them is "nobody has answered". This module is the - // one place that turns that zero into a statement about the claim, so it is the one place the - // difference matters: a reader told "No responses yet" about a claim with two hundred of them has - // been handed a wrong verdict rather than a missing one, with nothing on screen to say so. - it('says nothing at all when the counts never answered', () => { - // What a failed count query leaves behind: nothing loading any more, and no data. - renderVerdict(summary({ hasCounts: false })); - - expect(screen.queryByText('No responses yet')).toBeNull(); - expect(screen.queryByTestId('claim-split-bar')).toBeNull(); - }); - - it('says nothing while the summary is still being held back', () => { - // The claim page reaches this on every load: the summary waits for the vocabulary, so until the - // entity lands the hook has asked nothing and reports a zero that stands for nothing. Loading - // is already false by then, so the skeleton above does not cover it. - renderVerdict(summary({ hasCounts: false, isLoading: false })); - - expect(screen.queryByText('No responses yet')).toBeNull(); - }); - - it('draws a skeleton while the counts are still on their way', () => { - const { container } = renderVerdict(summary({ isLoading: true, hasCounts: false })); - - expect(screen.queryByText('No responses yet')).toBeNull(); - expect(container.querySelector('.animate-pulse')).not.toBeNull(); - }); -}); diff --git a/apps/web/core/claims/browse/claim-verdict.tsx b/apps/web/core/claims/browse/claim-verdict.tsx deleted file mode 100644 index 3b75e3aec6..0000000000 --- a/apps/web/core/claims/browse/claim-verdict.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import * as React from 'react'; - -import { ENTITY_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response'; - -import { Skeleton } from '~/design-system/skeleton'; -import { Text } from '~/design-system/text'; - -import { type ClaimResponseSummary, claimSummaryTier } from './claim-response-summary'; -import { ClaimSides, ClaimSplitBar } from './claim-summary'; - -/** - * Where opinion sits on a claim: one number, the split, and who is on each side. - * - * Reads the same `claimSummaryTier` every card reads, so the page and a card describing the same - * claim cannot say different things about it. - * - * At zero it invites a first response rather than rendering nothing at all, which is what it used - * to do on the state most claims are in — but only at a zero the server actually reported. A zero - * standing in for a failed or unasked question renders nothing, because the invitation is an - * assertion about the claim and those two are not. From the first response the share is shown, however small - * the sample: 93% of answered claims are unanimous and the median has two responses, so a "100%" - * here is usually standing on very little — and what keeps that honest is the responder counts - * directly beneath it, not withholding the number. - */ -export function ClaimVerdict({ - entityId, - spaceId, - responseKind, - summary, -}: { - entityId: string; - spaceId: string; - responseKind: ResponseKind; - summary: ClaimResponseSummary; -}) { - if (summary.isLoading) { - return ; - } - - // Nothing, where the counts never answered. - // - // `total: 0` is also what a failed count query and a held-back hook produce, and this module is - // the one place that turns a zero into a *claim about the world* — "No responses yet", followed - // by an invitation to be the first. Said over a claim with two hundred responses that is not a - // missing verdict but a wrong one, and the reader has no way to tell. On the claim page the - // held-back case is reached on every load: the summary waits for the vocabulary, so until the - // entity lands there is a window where nothing is loading and nothing has been asked. - // - // Rendering nothing is what this did before it learned to invite, and it is the honest answer to - // a question that was never put. - if (!summary.hasCounts) return null; - - const copy = ENTITY_RESPONSE_COPY[responseKind]; - const tier = claimSummaryTier(summary.total); - - // An invitation, where before there was nothing at all — and nothing is what the great majority - // of claims render, so this is the state most readers meet. - if (tier === 'invite') { - return ( -
- - No responses yet - - - {copy.firstResponsePrompt} - -
- ); - } - - const percent = summary.percent ?? 0; - - return ( -
-
- {/* The share and what it is a share *of*, on one baseline. They were stacked, which gave the - verb a line of its own for one small word and pushed everything under it down. Sharing a - line reads as one statement — "68% agree" — which is what it is, and it matches the - explore card exactly. */} - - {percent}% - - {/* "Agreements" → "agree", "Verifications" → "verify" reads wrong; use the action verb. */} - {copy.positiveAction.toLowerCase()} - - - {/* No Controversial tag here: it moved up to the hero chips, beside the type and tags, where - it says what kind of claim this is. Flagging it in both places said it twice. */} - - {summary.total} {summary.total === 1 ? 'response' : 'responses'} - -
- - - - -
- ); -} diff --git a/apps/web/core/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 ( - +