Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion apps/web/core/debates/browse/debate-feed-player.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(<DebateFeedPlayer debate={debate} active />);
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(<DebateFeedPlayer debate={debate} active />);
const { queryByText } = within(container);

expect(queryByText('For')).toBeNull();
expect(queryByText('Against')).not.toBeNull();
});
});

describe('overlay variants', () => {
Expand Down
49 changes: 31 additions & 18 deletions apps/web/core/debates/browse/debate-feed-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -919,24 +920,26 @@ function DebaterVideo({
</div>
)}

{/* 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. */}
<button
type="button"
onClick={openProfile}
they were reading something that debater had said. */}
<div
className={cx(
'absolute bottom-3 left-4 z-10 flex max-w-[55%] items-center gap-2 text-left transition-[padding-bottom] duration-150',
'pointer-events-none absolute bottom-3 left-4 z-10 flex w-[calc(100%-2rem)] items-center gap-2 transition-[padding-bottom] duration-150',
// Lifts with the claim stack, and for the same reason: the name shares the bottom band
// with the scrubber, so the scrubber appearing would otherwise draw a track through it.
// Padding rather than `bottom`, because the box is pinned by its bottom edge — the
Expand All @@ -945,11 +948,21 @@ function DebaterVideo({
clearScrubber === 'on-hover' && 'group-hover:pb-5'
)}
>
<span className="block size-5 shrink-0 overflow-hidden rounded-full bg-white">
<Avatar avatarUrl={participant?.avatar_cid} value={participant?.profile_space_id} size={20} />
</span>
<span className="truncate text-[1rem] tracking-[-0.35px] text-white">{name}</span>
</button>
<button
type="button"
onClick={openProfile}
className="pointer-events-auto flex min-w-0 max-w-[55%] items-center gap-2 text-left"
>
<span className="block size-5 shrink-0 overflow-hidden rounded-full bg-white">
<Avatar avatarUrl={participant?.avatar_cid} value={participant?.profile_space_id} size={20} />
</span>
<span className="truncate text-[1rem] tracking-[-0.35px] text-white">{name}</span>
</button>
{/* 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 && <DebatePositionChip label={participant.position_label} />}
</div>

{scrubber && <div className="absolute inset-x-0 bottom-0 z-10">{scrubber}</div>}
</div>
Expand Down
29 changes: 24 additions & 5 deletions apps/web/core/debates/debate-video-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DebateTileChip className={cx('max-w-full shrink-0 truncate text-text', tileChipSurface, className)}>
{label}
</DebateTileChip>
);
}

/**
* 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,
Expand Down Expand Up @@ -173,11 +196,7 @@ export function DebateVideoTile({
{(positionLabel || tileControls || status) && (
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-30 grid grid-cols-[minmax(0,1fr)_auto_minmax(auto,1fr)] items-center gap-2">
<div className="flex min-w-0 justify-start">
{positionLabel && (
<DebateTileChip className={cx('max-w-full truncate text-text', tileChipSurface)}>
{positionLabel}
</DebateTileChip>
)}
{positionLabel && <DebatePositionChip label={positionLabel} />}
</div>
<div className="pointer-events-auto">{tileControls}</div>
{/* No `min-w-0` here, unlike the label: it is what lets the column's `auto` minimum see
Expand Down
Loading