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) && (