diff --git a/apps/web/core/debates/browse/debate-feed-player.test.tsx b/apps/web/core/debates/browse/debate-feed-player.test.tsx index a0d5d53fca..b6e80ac3b1 100644 --- a/apps/web/core/debates/browse/debate-feed-player.test.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -171,6 +171,41 @@ describe('player layout', () => { expect(tiles).toHaveLength(2); expect(tiles.every(tile => tile?.className.includes('aspect-480/289'))).toBe(true); }); + + /** + * GEO-3022. The chip was dropped from the tile alongside the "Winner?" pill in #2439, which left + * the two videos saying who was speaking but not which side they were arguing — the one thing a + * viewer dropping into the middle of a debate cannot infer. + */ + it("shows each debater's position beside their name", () => { + mocks.controller = controllerFixture({ mutedByUser: true, turnSlot: 1 }); + const { container } = render(); + const { getByText } = within(container); + + // Beside the name and not inside its link: the position is a fact about the debater, not a + // second way to open their profile. + for (const [name, position] of [ + ['space-1', 'For'], + ['space-2', 'Against'], + ]) { + const chip = getByText(position); + const nameNode = getByText(name); + expect(chip.closest('button')).toBeNull(); + expect(chip.parentElement).toBe(nameNode.closest('button')?.parentElement); + } + }); + + it('draws no chip for a debater whose position has no label', () => { + mocks.controller = { + ...controllerFixture({ mutedByUser: true, turnSlot: 1 }), + slot1Participant: { ...participant(1), position_label: '' }, + }; + const { container } = render(); + const { queryByText } = within(container); + + expect(queryByText('For')).toBeNull(); + expect(queryByText('Against')).not.toBeNull(); + }); }); describe('overlay variants', () => { diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 08f0c2be51..40835bbcb2 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -6,6 +6,7 @@ import cx from 'classnames'; import type { Debate, DebateParticipant } from '~/core/debates/api'; import type { ClaimMarker } from '~/core/debates/claim-ticker'; +import { DebatePositionChip } from '~/core/debates/debate-video-tile'; import { type TurnState, clampSeconds, speakerLabel } from '~/core/debates/playback-utils'; import { useDebatePlayback } from '~/core/debates/use-debate-playback'; import { usePlaybackAnalytics } from '~/core/debates/use-playback-analytics'; @@ -919,24 +920,26 @@ function DebaterVideo({ )} - {/* Debater identity, opens their personal space in the side panel. On the left, opposite the - claim corner. + {/* Debater identity — who is speaking and which side they are arguing — opening their + personal space in the side panel. On the left, opposite the claim corner. + + The position chip is beside the name rather than inside the profile button: the button is + capped at 55% so the name cannot run the width of the tile, and a chip inside that cap + would be taken out of the name's share at exactly the widths where the name is already + truncating. Outside it, the name keeps its 55% and the chip takes its own width from the + remaining 45% — and the chip is not a link, which is the honest thing for it anyway. + + The row is `pointer-events-none` with the button opting back in, because it now spans the + band rather than hugging the name: everything it covers and does not use belongs to the + pause/play surface underneath. A generous 55%, and it no longer rations the claim corner's width: the corner shares this row and draws over it rather than sitting beside it. It also stays put — it used to fade out under a card, which cost the viewer the link to the debater's profile exactly when - they were reading something that debater had said. - - Absolute rather than the flex row #2466 put here. That row exists so the name cannot run - under the "Winner?" pill at a 312px gallery width; the pill and the position chip are off - the tile in this redesign — winner voting lives in the scorecard and the claims panel — so - the name has the band to itself and there is nothing left to overlap. The 55% is still - what keeps it clear of the claim card above. */} - + + {/* Guarded on the text rather than only on the participant: `position_label` is typed + non-null but arrives from geo-chat, and an empty one would draw a bare pill that says + nothing. The room tile guards it the same way. */} + {participant?.position_label && } + {scrubber &&
{scrubber}
} diff --git a/apps/web/core/debates/debate-video-tile.tsx b/apps/web/core/debates/debate-video-tile.tsx index 9b3cf2eea8..e36a6657d2 100644 --- a/apps/web/core/debates/debate-video-tile.tsx +++ b/apps/web/core/debates/debate-video-tile.tsx @@ -58,6 +58,29 @@ export function DebateTileChip({ ); } +/** + * Which side of the claim a debater is arguing, as it reads over their video: the tile chip in the + * shared fill, carrying whatever the claim's response kind calls that side — "Agree"/"Disagree" on + * a stance claim, "Verify"/"Dispute" on a factual one. + * + * One component rather than the recipe twice, because the room and the feed player state the same + * fact about the same person in the same corner of the same picture; the only difference is where + * they read it from. It also keeps the fill with the contrast note that justifies it — a caller + * reaching for `DebateTileChip` and picking its own background is exactly what {@link + * tileChipSurface} is written to prevent. + * + * `shrink-0`, so the position is never the thing that truncates: these labels are short and + * bounded, and half of "Disagree" is worse than a shortened name beside it. `max-w-full` is what + * caps it in a column narrower than the word — it truncates there rather than overflowing. + */ +export function DebatePositionChip({ label, className }: { label: string; className?: string }) { + return ( + + {label} + + ); +} + /** * One participant's tile, shared by the intro screen and the recording modal so the two have the * same geometry. Everything past the video is optional: the intro passes a label and an overlay, @@ -173,11 +196,7 @@ export function DebateVideoTile({ {(positionLabel || tileControls || status) && (
- {positionLabel && ( - - {positionLabel} - - )} + {positionLabel && }
{tileControls}
{/* No `min-w-0` here, unlike the label: it is what lets the column's `auto` minimum see