From e002da8b23c35554c5b463b3b209ee7609e52af0 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 18:07:02 -0700 Subject: [PATCH 1/2] fix(debates): say which side each debater is on in the video UI (GEO-3022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed player names both debaters over their video but no longer says what either of them is arguing for. Dropping into the middle of a debate, there is nothing in the picture that tells you who is on which side — you have to infer it from what is being said. The chip that carried it came off the tile in #2439, alongside the "Winner?" pill. Removing the pill was the point of that change — winner voting moved to the scorecard and the claims panel — and the position chip went with it because the two shared the flex row that was being torn out. Put it back beside the name, not inside the profile button: the button is capped at 55% of the tile so the name cannot run its full width, and a chip inside that cap would eat the name's share at exactly the widths where the name is already truncating. It is also not a link, which the old placement implied it was. The processed/shared video is composited outside this repo, so nothing here can burn the position into it. --- .../browse/debate-feed-player.test.tsx | 37 ++++++++++++- .../debates/browse/debate-feed-player.tsx | 53 ++++++++++++------- 2 files changed, 71 insertions(+), 19 deletions(-) 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 88d819383e..54c347ffde 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'; @@ -170,6 +170,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 fb88dc345e..996857698e 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 { DebateTileChip, tileChipSurface } 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'; @@ -882,24 +883,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, not just on the participant: the label is whatever the response + kind called the side ("Agree", "True", "For"), and an empty one would draw a bare pill + that says nothing. */} + {participant?.position_label && ( + + {participant.position_label} + + )} + {scrubber &&
{scrubber}
} From dbb4d948195b9a9054ba3525401ccf0cc394ac91 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 18:56:42 -0700 Subject: [PATCH 2/2] refactor(debates): one position chip for the room tile and the feed player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the restore. The recipe for "a debater's side, over their video" was written out twice — `DebateTileChip` plus `tileChipSurface` plus the truncation classes — once in the room tile and once in the feed player. It is the same fact about the same person in the same corner of the same picture, so it is one component now, and the fill stays with the contrast note that justifies it rather than being re-picked at each call site. `shrink-0` rather than letting the chip give up space: the labels are short and bounded ("Agree"/"Disagree", "Verify"/"Dispute" on a factual claim), and half of "Disagree" is worse than a shortened name beside it. `max-w-full` still caps it where the column is narrower than the word, which is the case the room tile's grid is written around. --- .../debates/browse/debate-feed-player.tsx | 14 ++++----- apps/web/core/debates/debate-video-tile.tsx | 29 +++++++++++++++---- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 996857698e..2fb4daa129 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -6,7 +6,7 @@ import cx from 'classnames'; import type { Debate, DebateParticipant } from '~/core/debates/api'; import type { ClaimMarker } from '~/core/debates/claim-ticker'; -import { DebateTileChip, tileChipSurface } from '~/core/debates/debate-video-tile'; +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'; @@ -921,14 +921,10 @@ function DebaterVideo({ {name} - {/* Guarded on the text, not just on the participant: the label is whatever the response - kind called the side ("Agree", "True", "For"), and an empty one would draw a bare pill - that says nothing. */} - {participant?.position_label && ( - - {participant.position_label} - - )} + {/* 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