From 7340b5b1db843141e17f0f4ef3d5a484a94b5f92 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 12:48:50 -0700 Subject: [PATCH 01/71] feat(debates): resolve when each claim was said, so claims can be tied to the video (GEO-2909) Claims are relations off a transcript block, so the graph knows which turn a claim came from but not when in the video it was said. Every interactive-playback idea needs that, and the extractor will not emit offsets for a while yet. This adds one resolver with three sources, tried in order: published timecodes on the block -> claim relation entity. Exact, and free. segment the claim's content words matched against the Whisper transcript the player already fetches for subtitles. Right span, loose at the edges, and it reports a confidence. block the turn's own window, when matching finds nothing worth keeping. So it answers for debates published years before timecodes existed, and silently improves per debate as offsets land. Callers read a moment and a confidence and never learn which source answered. Two findings from the test debate worth knowing, both documented in code: - A block's markdown is the verbatim concatenation of its Whisper segments, so locating a turn is string equality rather than fuzzy matching. All six blocks of the test debate matched exactly. - Relation `position` is not transcript order. Publishing assigns `Position.generate()`, which is random rather than monotonic, so the ordering `transcript-claims.ts` documented as "transcript order" is arbitrary on every debate. `claimsInSpokenOrder` recovers the real order from the recording; the stale comments are corrected rather than the behaviour changed, since nothing ships ordering by it yet. The only visible change is a "Said at 2:15" line on claims panel rows whose moment is known to the second. Rows that only know their turn stay quiet: a timecode reads as "here it is", and pointing at thirty seconds of video is worse than saying nothing. Panel ordering is deliberately untouched. Also lands the record of the hand-publish that produced the test data, and the matcher that generated it. --- .../browse/debate-claims-panel.test.tsx | 35 ++- .../debates/browse/debate-claims-panel.tsx | 40 ++- apps/web/core/debates/claim-timing.test.ts | 240 +++++++++++++++ apps/web/core/debates/claim-timing.ts | 279 ++++++++++++++++++ apps/web/core/debates/ontology.ts | 16 + .../core/debates/transcript-claims.test.ts | 60 ++++ apps/web/core/debates/transcript-claims.ts | 125 +++++++- apps/web/core/debates/use-claim-timings.ts | 59 ++++ .../io/debate-transcript-claims-document.ts | 32 +- apps/web/core/io/queries.ts | 5 + ...26-09-15-debate-claim-timecodes-matcher.py | 203 +++++++++++++ ...te-claim-timecodes-publish-instructions.md | 279 ++++++++++++++++++ 12 files changed, 1356 insertions(+), 17 deletions(-) create mode 100644 apps/web/core/debates/claim-timing.test.ts create mode 100644 apps/web/core/debates/claim-timing.ts create mode 100644 apps/web/core/debates/use-claim-timings.ts create mode 100644 docs/plans/2026-09-15-debate-claim-timecodes-matcher.py create mode 100644 docs/plans/2026-09-15-debate-claim-timecodes-publish-instructions.md diff --git a/apps/web/core/debates/browse/debate-claims-panel.test.tsx b/apps/web/core/debates/browse/debate-claims-panel.test.tsx index 828a341c00..41a2c297b5 100644 --- a/apps/web/core/debates/browse/debate-claims-panel.test.tsx +++ b/apps/web/core/debates/browse/debate-claims-panel.test.tsx @@ -26,6 +26,12 @@ const mocks = vi.hoisted(() => ({ rowGroups: [] as Array>, /** Whether each rendered row's controls asked for the account-level match, in render order. */ positionControlOffersDebate: [] as boolean[], + /** What `useDebateTranscript` answers with, for the timecode on each row. */ + transcript: { data: undefined, isSuccess: true, isError: false } as { + data?: { segments: unknown[] }; + isSuccess: boolean; + isError: boolean; + }, })); vi.mock('~/core/debates/use-debate-transcript-claims', () => ({ @@ -129,6 +135,10 @@ vi.mock('~/core/debates/hooks', () => ({ mocks.rowGroups.push(groups); return { claims: [], isLoading: false, isError: false }; }, + // Reached through `useClaimTimings`, which the panel calls to put a timecode on each row. + // `mocks.transcript` lets a test supply segments; by default there are none, which is the shape + // of every debate recorded before claim timecodes existed. + useDebateTranscript: () => mocks.transcript, })); vi.mock('~/design-system/prefetch-link', () => ({ @@ -140,13 +150,13 @@ vi.mock('~/design-system/avatar', () => ({ Avatar: () =>
Winner? })); function claim(id: string, text: string, overrides: Partial = {}): TranscriptClaim { - return { id, text, spaceId: CLAIM_SPACE, ...overrides }; + return { id, text, spaceId: CLAIM_SPACE, blockId: 'block-1', publishedTiming: null, ...overrides }; } function grouped(byAuthor: Record, unattributed: TranscriptClaim[] = []) { const byAuthorSpaceId = new Map(Object.entries(byAuthor)); const all = [...byAuthorSpaceId.values()].flat().concat(unattributed); - return { all, byAuthorSpaceId, unattributed, totalCount: all.length }; + return { all, byAuthorSpaceId, unattributed, blocks: [], totalCount: all.length }; } function participant(spaceId: string, name: string, slot: 1 | 2): DebateParticipant { @@ -224,6 +234,27 @@ describe('DebateClaimsPanel', () => { expect(mocks.positionControlOffersDebate.every(offers => offers === false)).toBe(true); }); + it('shows when a claim was said once the debate carries timecodes', () => { + mocks.claims = grouped({ + [PRESTON_SPACE]: [claim('claim-1', 'Sleep matters.', { publishedTiming: { startMs: 134_600, endMs: 143_140 } })], + }); + + render(); + + expect(within(cardFor('Preston Mantel')).getByText('Said at 2:15')).toBeInTheDocument(); + }); + + // Every debate recorded before timecodes existed, which is nearly all of them. The row is the + // same row it always was; it just says nothing about when. + it('says nothing about timing for a debate that has none', () => { + mocks.claims = grouped({ [PRESTON_SPACE]: [claim('claim-1', 'Sleep matters.')] }); + + render(); + + expect(within(cardFor('Preston Mantel')).getByText('Sleep matters.')).toBeInTheDocument(); + expect(screen.queryByText(/^Said at /)).not.toBeInTheDocument(); + }); + it('links each claim to its entity in the space the claim lives in', () => { mocks.claims = grouped({ [PRESTON_SPACE]: [claim('claim-1', 'Sleep matters.')] }); diff --git a/apps/web/core/debates/browse/debate-claims-panel.tsx b/apps/web/core/debates/browse/debate-claims-panel.tsx index 01fef7b19c..637be83cd3 100644 --- a/apps/web/core/debates/browse/debate-claims-panel.tsx +++ b/apps/web/core/debates/browse/debate-claims-panel.tsx @@ -5,11 +5,13 @@ import * as React from 'react'; import { ClaimSummary } from '~/core/claims/browse/claim-summary'; import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { Debate, DebateClaim } from '~/core/debates/api'; +import { type ClaimTiming, formatTimecode } from '~/core/debates/claim-timing'; import { sortClaimsByBest, useClaimsBestOrder } from '~/core/debates/claims-best-order'; import { useDebateClaimsBySpaces } from '~/core/debates/hooks'; import { PositionRow, useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; import { type TranscriptClaim, claimsForParticipant, unmatchedClaims } from '~/core/debates/transcript-claims'; +import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { useDebateVotes } from '~/core/debates/use-debate-votes'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; @@ -41,6 +43,9 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose const votes = useDebateVotes(debate); // Same query key as the feed's count badge, so opening the panel doesn't refetch. const { claims, isLoading, error } = useDebateTranscriptClaims(debate.id, debate.claim.space_id); + // When each claim was said. Published timecodes where they exist, matched against the transcript + // otherwise. Rows without an answer simply show no timecode. + const { timings } = useClaimTimings(debate.id, claims); // Every claim a debate publishes lands in the debate's own space, so one lookup covers the panel. const claimsSpaceId = React.useMemo( @@ -159,6 +164,7 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose } rowsByClaimId={rowsByClaimId} entitiesByClaimId={entitiesByClaimId} + timings={timings} isLoading={isOrdering} error={error} /> @@ -175,6 +181,7 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose claims={orphaned} rowsByClaimId={rowsByClaimId} entitiesByClaimId={entitiesByClaimId} + timings={timings} isLoading={false} error={null} /> @@ -189,12 +196,14 @@ function ClaimList({ claims, rowsByClaimId, entitiesByClaimId, + timings, isLoading, error, }: { claims: TranscriptClaim[]; rowsByClaimId: Map; entitiesByClaimId: Map; + timings: Map; isLoading: boolean; error: Error | null; }) { @@ -228,6 +237,7 @@ function ClaimList({ claim={claim} row={rowsByClaimId.get(claim.id) ?? null} entity={entitiesByClaimId.get(claim.id) ?? null} + timing={timings.get(claim.id) ?? null} /> ))} @@ -259,7 +269,17 @@ function ClaimList({ * controls are space-scoped, so there is nothing correct to point either one at — better a dead row * than one that navigates somewhere wrong or publishes a response into the wrong space. */ -function ClaimRow({ claim, row, entity }: { claim: TranscriptClaim; row: DebateClaim | null; entity: Entity | null }) { +function ClaimRow({ + claim, + row, + entity, + timing, +}: { + claim: TranscriptClaim; + row: DebateClaim | null; + entity: Entity | null; + timing: ClaimTiming | null; +}) { if (claim.spaceId === null) { return ( @@ -275,11 +295,29 @@ function ClaimRow({ claim, row, entity }: { claim: TranscriptClaim; row: DebateC {claim.text} + ); } +/** + * When this claim was said. + * + * Nothing is drawn for a claim whose moment is only known to the turn (`source: 'block'`, a ~30s + * window) or not at all. A timecode reads as "here it is", and pointing at a half-minute of video + * is a worse answer than staying quiet — the reader still has the claim text and the panel. + */ +function ClaimTimecode({ timing }: { timing: ClaimTiming | null }) { + if (!timing || timing.source === 'block') return null; + + return ( + + Said at {formatTimecode(timing.startMs)} + + ); +} + /** * The panel's compact rendition of the card's controls: the side pills and the summary, without the * card chrome around them. diff --git a/apps/web/core/debates/claim-timing.test.ts b/apps/web/core/debates/claim-timing.test.ts new file mode 100644 index 0000000000..d23a70beb9 --- /dev/null +++ b/apps/web/core/debates/claim-timing.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; + +import type { DebateTranscriptSegment } from './api'; +import { + LIVE_TIMING_CONFIDENCE, + claimsInSpokenOrder, + findBlockWindow, + formatTimecode, + matchClaimWindow, + resolveClaimTimings, +} from './claim-timing'; +import type { TranscriptBlock, TranscriptClaim } from './transcript-claims'; + +/** + * One turn of a real published debate, verbatim. + * + * Preston's third turn of "Trump's second term is making America more authoritarian" + * (`01a0a607-72dc-7cb0-9bf6-ebba15e97b67`), 2:00–2:45, with the three claims the extractor pulled + * out of it. Real data rather than invented sentences, because the thing under test is whether an + * extractor's *paraphrase* can be matched back to speech — and a hand-written fixture would quietly + * be written to match. + */ +function segment(index: number, startMs: number, endMs: number, text: string): DebateTranscriptSegment { + return { + id: `segment-${index}`, + participant_slot: 1, + position: false, + position_label: 'Disagree', + sequence_index: index, + start_ms: startMs, + end_ms: endMs, + text, + metadata: {}, + created_at: '2026-09-15T17:35:12.722937Z', + }; +} + +const SEGMENTS: DebateTranscriptSegment[] = [ + segment(0, 120000, 120530, 'Yeah, I totally agree with the'), + segment(1, 120530, 124030, 'intention of the founding'), + segment(2, 124030, 126080, 'fathers and the checks and'), + segment(3, 126080, 127720, 'balances comment.'), + segment(4, 127720, 129670, 'I still stand by that this'), + segment(5, 129670, 131520, 'trend has been continuing.'), + segment(6, 131520, 134600, 'It really comes down to the'), + segment(7, 134600, 138450, 'Supreme Court not providing'), + segment(8, 138450, 140960, 'those checks anymore.'), + segment(9, 140960, 143140, 'And you also have executive'), + segment(10, 143140, 145230, 'branches or governmental'), + segment(11, 145230, 147890, 'branches sidestepping the'), + segment(12, 147890, 149000, 'powers of Congress'), + segment(13, 149000, 150520, 'and suing companies.'), + segment(14, 150520, 154020, 'You look at the SEC under Gensler'), + segment(15, 154020, 157450, 'and his attacks on the crypto'), + segment(16, 157450, 159080, 'industry and then the'), + segment(17, 159080, 160640, 'ensuing legal battles that came'), + segment(18, 160640, 160960, 'up.'), + segment(19, 160960, 162210, "I'm not necessarily saying that"), + segment(20, 162210, 163080, "this isn't happening now."), + segment(21, 163080, 164140, "I'm saying that it's more of a"), + segment(22, 164140, 165000, "trend that's been happening."), +]; + +const BLOCK_TEXT = + 'Yeah, I totally agree with the intention of the founding fathers and the checks and balances comment. ' + + 'I still stand by that this trend has been continuing. It really comes down to the Supreme Court not ' + + 'providing those checks anymore. And you also have executive branches or governmental branches ' + + 'sidestepping the powers of Congress and suing companies. You look at the SEC under Gensler and his ' + + "attacks on the crypto industry and then the ensuing legal battles that came up. I'm not necessarily " + + "saying that this isn't happening now. I'm saying that it's more of a trend that's been happening."; + +const BLOCK: TranscriptBlock = { id: 'block-1', authorSpaceId: 'author-1', text: BLOCK_TEXT }; + +function claim(id: string, text: string, overrides: Partial = {}): TranscriptClaim { + return { id, text, spaceId: 'space-1', blockId: 'block-1', publishedTiming: null, ...overrides }; +} + +const SUPREME_COURT = claim( + 'claim-supreme-court', + 'The Supreme Court is no longer providing sufficient checks on executive power' +); +const SIDESTEPPING = claim( + 'claim-sidestepping', + 'Executive branches of the government are sidestepping the powers of Congress by suing companies directly' +); +const SEC = claim( + 'claim-sec', + 'The SEC under Gary Gensler has taken action against the crypto industry, resulting in legal battles' +); + +describe('findBlockWindow', () => { + it('locates the turn exactly, because a block is its segments joined', () => { + expect(findBlockWindow(BLOCK_TEXT, SEGMENTS)).toEqual({ startIndex: 0, endIndex: 22 }); + }); + + it('ignores punctuation and whitespace differences', () => { + const reflowed = BLOCK_TEXT.replace(/\s+/g, ' ').replace(/,/g, ''); + expect(findBlockWindow(reflowed, SEGMENTS)).toEqual({ startIndex: 0, endIndex: 22 }); + }); + + it('still places a turn whose tail was truncated after publishing', () => { + const truncated = SEGMENTS.slice(0, 16) + .map(value => value.text) + .join(' '); + expect(findBlockWindow(truncated, SEGMENTS)).toEqual({ startIndex: 0, endIndex: 15 }); + }); + + it('returns null rather than guessing when the text is from another debate', () => { + expect(findBlockWindow('Housing policy is downstream of interest rates.', SEGMENTS)).toBeNull(); + expect(findBlockWindow('', SEGMENTS)).toBeNull(); + }); +}); + +describe('matchClaimWindow', () => { + const window = { startIndex: 0, endIndex: 22 }; + + it('places each claim on the sentence that says it', () => { + const supremeCourt = matchClaimWindow(SUPREME_COURT.text, SEGMENTS, window); + expect(supremeCourt?.startMs).toBe(134600); + expect(supremeCourt?.endMs).toBe(143140); + + const sidestepping = matchClaimWindow(SIDESTEPPING.text, SEGMENTS, window); + expect(sidestepping?.startMs).toBe(140960); + expect(sidestepping?.endMs).toBe(150520); + + const sec = matchClaimWindow(SEC.text, SEGMENTS, window); + expect(sec?.startMs).toBe(150520); + expect(sec?.endMs).toBe(160640); + }); + + it('is confident enough about a well-matched claim to surface it live', () => { + const supremeCourt = matchClaimWindow(SUPREME_COURT.text, SEGMENTS, window); + expect(supremeCourt?.confidence).toBeGreaterThanOrEqual(LIVE_TIMING_CONFIDENCE); + }); + + it('prefers the tightest window that accounts for the claim, not the widest', () => { + const matched = matchClaimWindow(SUPREME_COURT.text, SEGMENTS, window); + // The whole turn contains these words too; a window that wide would be useless. + expect((matched?.endMs ?? 0) - (matched?.startMs ?? 0)).toBeLessThan(15_000); + }); + + it('scores a claim from a different debate far below the live bar', () => { + const unrelated = matchClaimWindow('Remote work made the housing squeeze worse.', SEGMENTS, window); + expect(unrelated?.confidence ?? 0).toBeLessThan(LIVE_TIMING_CONFIDENCE); + }); +}); + +describe('resolveClaimTimings', () => { + const claims = [SUPREME_COURT, SIDESTEPPING, SEC]; + + it('prefers published timecodes over matching, and says so', () => { + const published = claim('claim-published', SEC.text, { publishedTiming: { startMs: 1000, endMs: 2000 } }); + + const timings = resolveClaimTimings({ claims: [published], blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.get('claim-published')).toEqual({ + startMs: 1000, + endMs: 2000, + confidence: 1, + source: 'published', + }); + }); + + it('matches against the transcript when nothing is published', () => { + const timings = resolveClaimTimings({ claims, blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.size).toBe(3); + for (const [, timing] of timings) { + expect(timing.source).toBe('segment'); + expect(timing.startMs).toBeGreaterThanOrEqual(120000); + expect(timing.endMs).toBeLessThanOrEqual(165000); + } + }); + + it('falls back to the whole turn when the claim shares no vocabulary with it', () => { + const offTopic = claim('claim-off-topic', 'Interest rates drove the housing squeeze in coastal cities.'); + + const timings = resolveClaimTimings({ claims: [offTopic], blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.get('claim-off-topic')).toEqual({ + startMs: 120000, + endMs: 165000, + confidence: 0, + source: 'block', + }); + }); + + it('leaves a claim untimed when its turn cannot be located, rather than guessing', () => { + const orphan = claim('claim-orphan', SEC.text, { blockId: 'block-missing' }); + + const timings = resolveClaimTimings({ claims: [orphan], blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.has('claim-orphan')).toBe(false); + }); + + it('times nothing before the transcript has loaded', () => { + expect(resolveClaimTimings({ claims, blocks: [BLOCK], segments: [] }).size).toBe(0); + }); + + it('reads segments in time order even when the API returns them shuffled', () => { + const shuffled = [...SEGMENTS].reverse(); + + const timings = resolveClaimTimings({ claims, blocks: [BLOCK], segments: shuffled }); + + expect(timings.get(SUPREME_COURT.id)?.startMs).toBe(134600); + }); +}); + +describe('claimsInSpokenOrder', () => { + it('reorders claims the graph returned arbitrarily into the order they were said', () => { + // The order below is the order the API actually returns these three, which is not chronological + // — relations are published with random positions. + const asReturned = [SIDESTEPPING, SUPREME_COURT, SEC]; + const timings = resolveClaimTimings({ claims: asReturned, blocks: [BLOCK], segments: SEGMENTS }); + + expect(claimsInSpokenOrder(asReturned, timings).map(value => value.id)).toEqual([ + SUPREME_COURT.id, + SIDESTEPPING.id, + SEC.id, + ]); + }); + + it('keeps untimed claims at the end in their incoming order', () => { + const orphanA = claim('orphan-a', 'one', { blockId: 'missing' }); + const orphanB = claim('orphan-b', 'two', { blockId: 'missing' }); + const claims = [orphanA, SEC, orphanB]; + const timings = resolveClaimTimings({ claims, blocks: [BLOCK], segments: SEGMENTS }); + + expect(claimsInSpokenOrder(claims, timings).map(value => value.id)).toEqual([SEC.id, 'orphan-a', 'orphan-b']); + }); +}); + +describe('formatTimecode', () => { + it('formats a timecode the way a player does', () => { + expect(formatTimecode(0)).toBe('0:00'); + expect(formatTimecode(9_400)).toBe('0:09'); + expect(formatTimecode(134_600)).toBe('2:15'); + expect(formatTimecode(270_000)).toBe('4:30'); + }); +}); diff --git a/apps/web/core/debates/claim-timing.ts b/apps/web/core/debates/claim-timing.ts new file mode 100644 index 0000000000..cf0aefc590 --- /dev/null +++ b/apps/web/core/debates/claim-timing.ts @@ -0,0 +1,279 @@ +import type { DebateTranscriptSegment } from './api'; +import type { TranscriptBlock, TranscriptClaim } from './transcript-claims'; + +/** + * Where a claim's moment on the debate timeline came from, best first. + * + * - `published` — read off the block → claim relation entity. Exact, and free. + * - `segment` — matched against the Whisper transcript. Right span, a second or two loose at the + * edges, and it carries a confidence. + * - `block` — only the turn is known, so the claim is placed across the whole turn. Coarse but + * never wrong. + * + * The resolver tries them in that order, so a debate published before timecodes existed still gets + * an answer and silently improves the day offsets land for it. Nothing above this module knows + * which source answered; surfaces read `confidence` and decide how much to trust it. + */ +export type ClaimTimingSource = 'published' | 'segment' | 'block'; + +export type ClaimTiming = { + /** Milliseconds from the start of the debate timeline. */ + startMs: number; + endMs: number; + /** + * How much to trust it: 1 for published, the match score for `segment`, 0 for `block`. + * See {@link LIVE_TIMING_CONFIDENCE}. + */ + confidence: number; + source: ClaimTimingSource; +}; + +export type TimedClaim = TranscriptClaim & { timing: ClaimTiming | null }; + +/** + * The bar a matched claim has to clear before it may be drawn as a moment — a marker on the + * scrubber, a card that surfaces mid-playback. + * + * Measured against the test debate, where every claim above this was placed on the right sentence. + * Below it the match is usually still in the right region but can start several seconds early, and + * a card that pops early tells a viewer a debater said something they had not yet said. Those + * claims stay in the panel, which is a list and makes no claim about *when*. + */ +export const LIVE_TIMING_CONFIDENCE = 0.55; + +/** + * Below this the match is not worth keeping at all and the claim falls back to its turn. + * + * Deliberately not zero: the best window always scores something, and a near-zero score means the + * extractor's paraphrase shares almost no vocabulary with the turn — at which point the whole turn + * is a more honest answer than a confidently wrong five seconds. + */ +export const USABLE_TIMING_CONFIDENCE = 0.35; + +/** How many consecutive segments a claim may span. Whisper segments here run 1–3 seconds. */ +const MAX_WINDOW_SEGMENTS = 6; + +/** + * Words carrying no identifying signal, dropped before scoring. + * + * Skewed towards the vocabulary of argument rather than a general stopword list: "claim", "think" + * and "point" appear in most turns of most debates and match everything, which is the same problem + * "the" has. + */ +const STOPWORDS = new Set([ + 'about', 'after', 'again', 'against', 'all', 'also', 'and', 'any', 'are', 'because', 'been', + 'being', 'both', 'but', 'can', 'claim', 'could', 'did', 'does', 'doing', 'done', 'down', 'each', + 'even', 'every', 'for', 'from', 'get', 'going', 'had', 'has', 'have', 'her', 'here', 'him', 'his', + 'how', 'into', 'its', 'just', 'know', 'like', 'look', 'made', 'make', 'many', 'may', 'mean', + 'might', 'more', 'most', 'much', 'must', 'not', 'now', 'off', 'one', 'only', 'other', 'our', + 'out', 'over', 'own', 'point', 'really', 'said', 'same', 'say', 'see', 'she', 'should', 'some', + 'such', 'than', 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'thing', + 'things', 'think', 'this', 'those', 'through', 'too', 'two', 'under', 'use', 'very', 'want', + 'was', 'way', 'well', 'were', 'what', 'when', 'where', 'which', 'while', 'who', 'why', 'will', + 'with', 'would', 'yeah', 'yes', 'you', 'your', +]); + +function words(text: string): string[] { + return text.toLowerCase().match(/[a-z0-9']+/g) ?? []; +} + +/** The words worth matching on: not stopwords, and long enough to mean something. */ +export function contentWords(text: string): string[] { + return words(text).filter(word => word.length > 2 && !STOPWORDS.has(word)); +} + +/** Whitespace- and punctuation-insensitive form, for comparing a block against its segments. */ +function normalize(text: string): string { + return words(text).join(' '); +} + +/** Segments on the debate timeline, earliest first. */ +function inTimeOrder(segments: DebateTranscriptSegment[]): DebateTranscriptSegment[] { + return [...segments].sort((a, b) => + a.start_ms === b.start_ms ? a.sequence_index - b.sequence_index : a.start_ms - b.start_ms + ); +} + +export type SegmentWindow = { startIndex: number; endIndex: number }; + +/** + * The run of consecutive segments whose text is this transcript block. + * + * Not a fuzzy match, and it does not need to be: publishing builds a block by joining consecutive + * same-speaker Whisper segments, so the block's text *is* that run, modulo the whitespace and + * punctuation `normalize` removes. On the test debate all six blocks matched exactly. + * + * The prefix fallback covers a block whose tail was edited or truncated after publishing — better + * to place the turn slightly short than to drop every claim in it. A block that matches nothing + * returns null and its claims end up untimed, which surfaces as "no moment known" rather than a + * guess. + */ +export function findBlockWindow(blockText: string, segments: DebateTranscriptSegment[]): SegmentWindow | null { + const target = normalize(blockText); + if (target.length === 0) return null; + + let bestPrefix: (SegmentWindow & { length: number }) | null = null; + + for (let start = 0; start < segments.length; start += 1) { + let joined = ''; + + for (let end = start; end < segments.length; end += 1) { + const next = normalize(segments[end].text); + joined = joined.length === 0 ? next : `${joined} ${next}`; + + // Past the target's length this run can only get further away. + if (joined.length > target.length) break; + if (joined === target) return { startIndex: start, endIndex: end }; + + if (target.startsWith(joined) && (bestPrefix === null || joined.length > bestPrefix.length)) { + bestPrefix = { startIndex: start, endIndex: end, length: joined.length }; + } + } + } + + // Only trust a partial block match once it accounts for most of the turn. + if (bestPrefix && bestPrefix.length >= target.length * 0.6) { + return { startIndex: bestPrefix.startIndex, endIndex: bestPrefix.endIndex }; + } + + return null; +} + +/** + * How well a window of transcript text accounts for a claim, in [0, 1]. + * + * Containment, not similarity: the claim is an extractor paraphrase, so it will not share a + * sentence shape with what was said, but its content words will nearly all appear in the seconds + * where it was said. Precision is mixed in at a quarter weight purely to break ties towards the + * tightest window — without it a six-segment window contains everything a one-segment window does + * and always wins, and the claim gets placed across the whole turn. + */ +export function scoreWindow(claimWords: string[], windowText: string): number { + if (claimWords.length === 0) return 0; + + const windowWords = new Set(contentWords(windowText)); + if (windowWords.size === 0) return 0; + + const hits = claimWords.reduce((count, word) => (windowWords.has(word) ? count + 1 : count), 0); + const containment = hits / claimWords.length; + const precision = hits / windowWords.size; + + return containment * (0.75 + 0.25 * precision); +} + +/** The best-scoring window of consecutive segments within `[lo, hi]`, or null if there is none. */ +export function matchClaimWindow( + claimText: string, + segments: DebateTranscriptSegment[], + window: SegmentWindow +): (ClaimTiming & { source: 'segment' }) | null { + const claimWords = contentWords(claimText); + if (claimWords.length === 0) return null; + + let best: (ClaimTiming & { source: 'segment' }) | null = null; + + for (let start = window.startIndex; start <= window.endIndex; start += 1) { + let text = ''; + + for (let end = start; end <= Math.min(start + MAX_WINDOW_SEGMENTS - 1, window.endIndex); end += 1) { + text = text.length === 0 ? segments[end].text : `${text} ${segments[end].text}`; + const confidence = scoreWindow(claimWords, text); + + if (best === null || confidence > best.confidence) { + best = { + startMs: segments[start].start_ms, + endMs: segments[end].end_ms, + confidence, + source: 'segment', + }; + } + } + } + + return best; +} + +export type ResolveClaimTimingsInput = { + claims: TranscriptClaim[]; + blocks: TranscriptBlock[]; + /** The debate's Whisper transcript. Empty until it loads, or for a debate that has none. */ + segments: DebateTranscriptSegment[]; +}; + +/** + * Every claim's moment on the debate timeline, keyed by claim id. + * + * A claim with no published timecodes and no locatable block is absent from the map rather than + * given a zero, so callers can tell "at the start" from "unknown". + */ +export function resolveClaimTimings({ claims, blocks, segments }: ResolveClaimTimingsInput): Map { + const timings = new Map(); + const ordered = inTimeOrder(segments); + + // One block window per block, not per claim: locating a block is the expensive half, and a turn + // with five claims would otherwise pay for it five times. + const windows = new Map(); + const blocksById = new Map(blocks.map(block => [block.id, block])); + + const windowFor = (blockId: string): SegmentWindow | null => { + if (windows.has(blockId)) return windows.get(blockId) ?? null; + const block = blocksById.get(blockId); + const window = block && ordered.length > 0 ? findBlockWindow(block.text, ordered) : null; + windows.set(blockId, window); + return window; + }; + + for (const claim of claims) { + if (claim.publishedTiming) { + timings.set(claim.id, { ...claim.publishedTiming, confidence: 1, source: 'published' }); + continue; + } + + const window = windowFor(claim.blockId); + if (!window) continue; + + const matched = matchClaimWindow(claim.text, ordered, window); + if (matched && matched.confidence >= USABLE_TIMING_CONFIDENCE) { + timings.set(claim.id, matched); + continue; + } + + timings.set(claim.id, { + startMs: ordered[window.startIndex].start_ms, + endMs: ordered[window.endIndex].end_ms, + confidence: 0, + source: 'block', + }); + } + + return timings; +} + +/** + * Claims in the order they were actually said, which is not the order the graph returns them in. + * + * Relations are published with random positions (see `transcript-claims.ts`), so this is the only + * way to get chronological order. Claims with no known moment keep their incoming order and sit at + * the end, where a list that is mostly chronological is still useful. + */ +export function claimsInSpokenOrder(claims: TranscriptClaim[], timings: Map): TimedClaim[] { + return claims + .map((claim, index) => ({ claim, index, timing: timings.get(claim.id) ?? null })) + .sort((a, b) => { + if (a.timing && b.timing) { + return a.timing.startMs === b.timing.startMs ? a.index - b.index : a.timing.startMs - b.timing.startMs; + } + if (a.timing) return -1; + if (b.timing) return 1; + return a.index - b.index; + }) + .map(({ claim, timing }) => ({ ...claim, timing })); +} + +/** `2:05`, for a timecode chip. */ +export function formatTimecode(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} diff --git a/apps/web/core/debates/ontology.ts b/apps/web/core/debates/ontology.ts index 8edc800a49..5565489860 100644 --- a/apps/web/core/debates/ontology.ts +++ b/apps/web/core/debates/ontology.ts @@ -105,3 +105,19 @@ export const TEXT_BLOCK_TYPE_ID = SystemIds.TEXT_BLOCK; // 76474f2f… export const MARKDOWN_CONTENT_PROPERTY_ID = SystemIds.MARKDOWN_CONTENT; // e3e363d1… (matches spec) export const AUTHORS_PROPERTY_ID = '91a9e2f6e51a48f7997661de8561b690'; // ContentIds.AUTHORS_PROPERTY (matches spec) export const SOURCES_PROPERTY_ID = '49c5d5e1679a4dbdbfd33f618f227c94'; // ContentIds.SOURCES_PROPERTY (matches spec) + +/** + * When a claim was said, in milliseconds from the start of the debate timeline — the same origin + * the player's scrubber and `turn_durations_ms` use. + * + * These live on the *relation entity* of the transcript block → claim relation, not on the claim: + * one claim can be stated in two different turns, and each statement has its own moment. The + * relation entity is also typed `Selector` and pointed at `Debate videos`, which is the graph's + * existing shape for "this relation points at a span of its target" — the same one `Reply to` uses + * to anchor a comment to a range of text. + * + * Both properties already existed in the Geo ontology space, unused, declared as Integer. Nothing + * was minted for this. Note the API serialises Integer values as strings. + */ +export const CLAIM_START_OFFSET_PROPERTY_ID = 'a1d1cb557b184238ba0ec78ba7f289fb'; +export const CLAIM_END_OFFSET_PROPERTY_ID = '79a677b597f84ca8a1cf24eef7837b61'; diff --git a/apps/web/core/debates/transcript-claims.test.ts b/apps/web/core/debates/transcript-claims.test.ts index 04bf4dbfd5..83a4b50d23 100644 --- a/apps/web/core/debates/transcript-claims.test.ts +++ b/apps/web/core/debates/transcript-claims.test.ts @@ -17,12 +17,18 @@ type Claim = { position?: string; /** null models a claim the graph reports no space for. */ spaceId?: string | null; + /** + * Values on the block → claim relation entity, where timecodes live. Integers arrive from the + * API as strings, so these fixtures are written as strings too. + */ + offsets?: Array<{ propertyId: string; integer?: string | null } | null>; }; type Block = { id: string; position?: string; author?: string | null; + markdown?: string | null; claims: Claim[]; }; @@ -38,11 +44,13 @@ function response(blocks: Block[], transcriptPosition = 'a0'): DebateTranscriptC position: block.position ?? 'a0', toEntity: { id: block.id, + markdown: block.markdown === undefined ? [] : [{ spaceId: SPACE, text: block.markdown }], authors: block.author === null ? [] : [{ position: 'a0', toEntity: { id: block.author ?? PRESTON } }], claims: block.claims.map(claim => { const spaceId = claim.spaceId === undefined ? SPACE : claim.spaceId; return { position: claim.position ?? 'a0', + entity: claim.offsets === undefined ? null : { valuesList: claim.offsets }, toEntity: { id: claim.id, // Aggregated across spaces, so it is only the resolver's last resort. @@ -447,3 +455,55 @@ describe('space scoping', () => { expect(claimsForParticipant(grouped, PRESTON)[0].spaceId).toBe(NAMING); }); }); + +describe('published timecodes', () => { + const START = 'a1d1cb557b184238ba0ec78ba7f289fb'; + const END = '79a677b597f84ca8a1cf24eef7837b61'; + + const offsets = (start: string | null, end: string | null) => [ + { propertyId: START, integer: start }, + { propertyId: END, integer: end }, + ]; + + it('reads the pair off the relation entity, parsing the strings the API sends', () => { + const { all } = group(response([{ id: 'block-1', claims: [{ id: 'c1', offsets: offsets('134600', '143140') }] }])); + + expect(all[0].publishedTiming).toEqual({ startMs: 134600, endMs: 143140 }); + }); + + it('reports no timing for the debates that predate timecodes', () => { + const { all } = group(response([{ id: 'block-1', claims: [{ id: 'c1' }] }])); + + expect(all[0].publishedTiming).toBeNull(); + }); + + // Each of these would otherwise be drawn on the timeline as a real moment. Falling back to + // matching against the transcript is both honest and, in practice, right. + it.each([ + ['only a start', offsets('1000', null)], + ['only an end', offsets(null, '2000')], + ['an unparseable value', offsets('about a minute in', '2000')], + ['a negative start', offsets('-500', '2000')], + ['an end at the start', offsets('2000', '2000')], + ['an end before the start', offsets('4000', '2000')], + ])('discards a pair with %s', (_label, values) => { + const { all } = group(response([{ id: 'block-1', claims: [{ id: 'c1', offsets: values }] }])); + + expect(all[0].publishedTiming).toBeNull(); + }); + + it('carries the block each claim was said in, so its turn can be located on the recording', () => { + const { all, blocks } = group( + response([ + { id: 'block-1', markdown: 'The first turn, as spoken.', claims: [{ id: 'c1' }] }, + { id: 'block-2', markdown: 'The reply.', author: ARTURAS, claims: [{ id: 'c2' }] }, + ]) + ); + + expect(all.map(claim => claim.blockId)).toEqual(['block-1', 'block-2']); + expect(blocks).toEqual([ + { id: 'block-1', authorSpaceId: PRESTON, text: 'The first turn, as spoken.' }, + { id: 'block-2', authorSpaceId: ARTURAS, text: 'The reply.' }, + ]); + }); +}); diff --git a/apps/web/core/debates/transcript-claims.ts b/apps/web/core/debates/transcript-claims.ts index a2ec55e778..c40d4260c9 100644 --- a/apps/web/core/debates/transcript-claims.ts +++ b/apps/web/core/debates/transcript-claims.ts @@ -1,6 +1,10 @@ import { Position } from '@geoprotocol/geo-sdk/lite'; -import { NAME_PROPERTY_ID } from '~/core/debates/ontology'; +import { + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, + NAME_PROPERTY_ID, +} from '~/core/debates/ontology'; import { uuidToHex } from '~/core/id/normalize'; import { entityHomeSpaceId } from '~/core/utils/space/entity-home-space'; @@ -17,15 +21,46 @@ export type TranscriptClaim = { * claim unlinkable and unrespondable rather than pointing it at the wrong space. */ spaceId: string | null; + /** + * The transcript block this claim was first seen on — the turn it was said in. + * + * Carried so `claim-timing.ts` can place the claim inside that turn's slice of the recording + * rather than searching the whole debate, which is both slower and a way to match a phrase the + * other debater said. + */ + blockId: string; + /** + * The claim's timecodes as published on the block → claim relation entity, in milliseconds from + * the start of the debate timeline. Null for every debate published before timecodes existed, + * which is nearly all of them — the resolver recovers those from the transcript instead. + */ + publishedTiming: { startMs: number; endMs: number } | null; +}; + +/** One turn of the debate as published, with the text needed to locate it on the recording. */ +export type TranscriptBlock = { + id: string; + /** The speaker's personal-space id, or null on a block with no `Authors` relation. */ + authorSpaceId: string | null; + /** The turn's verbatim text, in transcript order within the turn. */ + text: string; }; export type DebateTranscriptClaims = { - /** Every claim, deduped, in transcript order. */ + /** + * Every claim, deduped, in the order the graph returned them. + * + * Not chronological, despite how it reads: relations are published with `Position.generate()`, + * which is random rather than monotonic, so `position` order is arbitrary. `claim-timing.ts` + * recovers the real order from the recording. See {@link groupTranscriptClaims}. + */ all: TranscriptClaim[]; /** Claims keyed by the hex form of the speaker's `profile_space_id`. */ byAuthorSpaceId: Map; /** Claims on a block with no `Authors` relation. Empty for anything we publish. */ unattributed: TranscriptClaim[]; + /** The debate's turns, in the same arbitrary order, keyed by block id. */ + blocks: TranscriptBlock[]; totalCount: number; }; @@ -33,28 +68,79 @@ export const EMPTY_TRANSCRIPT_CLAIMS: DebateTranscriptClaims = { all: [], byAuthorSpaceId: new Map(), unattributed: [], + blocks: [], totalCount: 0, }; -type PresentRelation = { position?: string | null; toEntity: T }; +type PresentRelation = { position?: string | null; entity?: E; toEntity: T }; /** - * Drop relations the API returned as null (or pointing at nothing) and put the rest in graph - * order. `relationsList` does not come back sorted by `position`, so ordering has to happen here - * for claims to read in transcript order. + * Drop relations the API returned as null (or pointing at nothing) and put the rest in `position` + * order. + * + * This is a stable order, not a chronological one. Every relation here was published with + * `Position.generate()`, which is random rather than monotonic, so sorting by it produces the same + * arbitrary sequence on every read rather than the order the turns were spoken in. That is enough + * for grouping and dedupe, which is all this function feeds; anything that needs real order takes + * it from `claim-timing.ts`. + * + * The relation's own `entity` is carried through, because on a block → claim relation that is where + * the claim's timecodes live. */ -function presentRelations( - relations: Array<{ position?: string | null; toEntity: T | null } | null> | null | undefined -): PresentRelation[] { - const present: PresentRelation[] = []; +function presentRelations( + relations: Array<{ position?: string | null; entity?: E; toEntity: T | null } | null> | null | undefined +): PresentRelation[] { + const present: PresentRelation[] = []; for (const relation of relations ?? []) { - if (relation?.toEntity) present.push({ position: relation.position, toEntity: relation.toEntity }); + if (relation?.toEntity) { + present.push({ position: relation.position, entity: relation.entity, toEntity: relation.toEntity }); + } } return present.sort((a, b) => Position.compare(a.position ?? null, b.position ?? null)); } +/** The turn's text as published in this space, or '' when the graph holds none. */ +function blockTextInSpace( + markdown: Array<{ spaceId: string; text?: string | null } | null> | null | undefined, + spaceId: string +): string { + for (const value of markdown ?? []) { + if (!value || typeof value.text !== 'string') continue; + if (uuidToHex(value.spaceId) === uuidToHex(spaceId)) return value.text.trim(); + } + return ''; +} + +/** + * The timecodes published on a block → claim relation entity, or null when it carries none. + * + * Integer values arrive as strings, so they are parsed rather than trusted. A pair that is + * incomplete, unparseable, negative, or not strictly increasing is discarded whole: a half-answer + * here would be drawn as a real moment on the timeline, and falling back to matching is both + * honest and usually right. + */ +function publishedTiming( + values: Array<{ propertyId: string; integer?: string | null } | null> | null | undefined +): { startMs: number; endMs: number } | null { + let startMs: number | null = null; + let endMs: number | null = null; + + for (const value of values ?? []) { + if (!value || value.integer === null || value.integer === undefined) continue; + const parsed = Number(value.integer); + if (!Number.isFinite(parsed)) continue; + if (uuidToHex(value.propertyId) === uuidToHex(CLAIM_START_OFFSET_PROPERTY_ID)) startMs = parsed; + if (uuidToHex(value.propertyId) === uuidToHex(CLAIM_END_OFFSET_PROPERTY_ID)) endMs = parsed; + } + + if (startMs === null || endMs === null) return null; + if (startMs < 0 || endMs <= startMs) return null; + + return { startMs, endMs }; +} + type ClaimEntityNaming = { name?: string | null; spaceIds?: Array | null; @@ -116,6 +202,7 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId const all: TranscriptClaim[] = []; const byAuthorSpaceId = new Map(); const unattributed: TranscriptClaim[] = []; + const blocks: TranscriptBlock[] = []; const rowsByClaimId = new Map(); /** `${authorKey}:${claimKey}`; the empty author key stands for unattributed. */ const seenPairs = new Set(); @@ -128,6 +215,12 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId // one turn's claims land in one group rather than being counted under each speaker. const authorSpaceId = presentRelations(blockEntity.authors)[0]?.toEntity.id; + blocks.push({ + id: blockEntity.id, + authorSpaceId: authorSpaceId ?? null, + text: blockTextInSpace(blockEntity.markdown, spaceId), + }); + for (const claim of presentRelations(blockEntity.claims)) { const claimEntity = claim.toEntity; const key = uuidToHex(claimEntity.id); @@ -137,7 +230,13 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId const resolved = resolveClaimNaming(claimEntity, spaceId); // A claim with no name has nothing to render — its text *is* its name. if (!resolved.text) continue; - row = { id: claimEntity.id, text: resolved.text, spaceId: resolved.spaceId }; + row = { + id: claimEntity.id, + text: resolved.text, + spaceId: resolved.spaceId, + blockId: blockEntity.id, + publishedTiming: publishedTiming(claim.entity?.valuesList), + }; rowsByClaimId.set(key, row); all.push(row); } @@ -159,7 +258,7 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId } } - return { all, byAuthorSpaceId, unattributed, totalCount: all.length }; + return { all, byAuthorSpaceId, unattributed, blocks, totalCount: all.length }; } /** diff --git a/apps/web/core/debates/use-claim-timings.ts b/apps/web/core/debates/use-claim-timings.ts new file mode 100644 index 0000000000..081c7886fa --- /dev/null +++ b/apps/web/core/debates/use-claim-timings.ts @@ -0,0 +1,59 @@ +'use client'; + +import * as React from 'react'; + +import { type ClaimTiming, resolveClaimTimings } from './claim-timing'; +import { useDebateTranscript } from './hooks'; +import type { DebateTranscriptClaims } from './transcript-claims'; + +export type ClaimTimingsResult = { + /** Claim id → when it was said. Absent for a claim whose moment could not be established. */ + timings: Map; + /** + * Whether every answer that is coming has arrived. + * + * False while the transcript is still loading, so a surface that reorders by time can hold + * rather than paint an order it is about to change under the reader — the same reason the claims + * panel holds its rows back while the ranking loads. + */ + isReady: boolean; +}; + +const EMPTY_TIMINGS = new Map(); + +/** + * When each of a debate's claims was said, on the debate timeline. + * + * Published timecodes come back with the claims themselves and need nothing else. Everything else + * is recovered by matching the claims against the Whisper transcript, which the player already + * fetches for subtitles — so on a debate card that is playing, this is free, and on one that is not + * it is a single extra request. + * + * Deliberately a hook over `DebateTranscriptClaims` rather than a wrapper around + * `useDebateTranscriptClaims`: the panel, the feed badge and the player each already hold the + * claims, and re-fetching them per surface is what the shared query key exists to avoid. + */ +export function useClaimTimings( + debateId: string | null, + claims: DebateTranscriptClaims, + enabled = true +): ClaimTimingsResult { + // Every claim already carries published timecodes, so the transcript would tell us nothing we do + // not already know. Nearly always false today and nearly always true later, which is the point. + const allPublished = claims.all.length > 0 && claims.all.every(claim => claim.publishedTiming !== null); + const needsTranscript = enabled && debateId !== null && claims.all.length > 0 && !allPublished; + + const transcript = useDebateTranscript(debateId ?? '', 'json', needsTranscript); + const segments = transcript.data?.segments; + + const timings = React.useMemo(() => { + if (claims.all.length === 0) return EMPTY_TIMINGS; + return resolveClaimTimings({ claims: claims.all, blocks: claims.blocks, segments: segments ?? [] }); + }, [claims.all, claims.blocks, segments]); + + // A failed transcript fetch is ready, not pending: published timings still resolved, and the rest + // are not coming. Reporting "still loading" forever would hold a time-ordered surface empty. + const isReady = !needsTranscript || transcript.isSuccess || transcript.isError; + + return { timings, isReady }; +} diff --git a/apps/web/core/io/debate-transcript-claims-document.ts b/apps/web/core/io/debate-transcript-claims-document.ts index 67948514ea..285e237af8 100644 --- a/apps/web/core/io/debate-transcript-claims-document.ts +++ b/apps/web/core/io/debate-transcript-claims-document.ts @@ -33,6 +33,8 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` $claimsPropertyId: UUID! $spaceId: UUID! $namePropertyId: UUID! + $markdownPropertyId: UUID! + $offsetPropertyIds: [UUID!] ) { entity(id: $id) { transcripts: relationsList(filter: { typeId: { is: $transcriptsPropertyId }, spaceId: { is: $spaceId } }) { @@ -43,6 +45,13 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` position toEntity { id + # The turn's text, per space. This is the verbatim concatenation of the speaker's + # Whisper segments, which is what lets claim-timing.ts locate the turn on the + # video timeline by matching it back against the transcript. + markdown: valuesList(filter: { propertyId: { is: $markdownPropertyId } }) { + spaceId + text + } authors: relationsList(filter: { typeId: { is: $authorsPropertyId }, spaceId: { is: $spaceId } }) { toEntity { id @@ -50,6 +59,16 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` } claims: relationsList(filter: { typeId: { is: $claimsPropertyId }, spaceId: { is: $spaceId } }) { position + # The relation's own entity, which is where the claim's timecodes live — not on the + # claim, because one claim can be stated in two turns and each statement has its + # own moment. Empty for every debate published before timecodes existed, which is + # all of them bar the test debate; claim-timing.ts falls back to matching. + entity { + valuesList(filter: { propertyId: { in: $offsetPropertyIds }, spaceId: { is: $spaceId } }) { + propertyId + integer + } + } toEntity { id # Not space-scoped, so only a last resort — see the note above. @@ -76,6 +95,14 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` type RelationNode = { position?: string | null; toEntity: T | null } | null; +/** A block → claim relation, which carries the claim's timecodes on its own entity. */ +type ClaimRelationNode = { + position?: string | null; + /** Integer values arrive as strings, the way the API serialises them. */ + entity?: { valuesList?: Array<{ propertyId: string; integer?: string | null } | null> | null } | null; + toEntity: ClaimEntity | null; +} | null; + type ClaimEntity = { id: string; name?: string | null; @@ -91,8 +118,9 @@ export type DebateTranscriptClaimsQuery = { blocks: Array< RelationNode<{ id: string; + markdown?: Array<{ spaceId: string; text?: string | null } | null> | null; authors: Array> | null; - claims: Array> | null; + claims: Array | null; }> > | null; }> @@ -108,6 +136,8 @@ type DebateTranscriptClaimsVariables = { claimsPropertyId: string; spaceId: string; namePropertyId: string; + markdownPropertyId: string; + offsetPropertyIds: string[]; }; export const debateTranscriptClaimsDocument = parse(DEBATE_TRANSCRIPT_CLAIMS_SOURCE) as TypedDocumentNode< diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index 8cacb46472..06bfaabfa6 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -6,10 +6,13 @@ import { COMMENT_REPLY_TO_ID, COMMENT_TYPE_ID } from '~/core/comment-ids'; import { AUTHORS_PROPERTY_ID, BLOCKS_PROPERTY_ID, + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, DEBATE_CLAIMS_PROPERTY_ID, DEBATE_OPPOSED_BY_PROPERTY_ID, DEBATE_SUPPORTED_BY_PROPERTY_ID, DEBATE_TRANSCRIPTS_PROPERTY_ID, + MARKDOWN_CONTENT_PROPERTY_ID, NAME_PROPERTY_ID, VOTE_DEBATES_PROPERTY_ID, VOTE_TYPE_ID, @@ -720,6 +723,8 @@ export function getDebateTranscriptClaims(debateEntityId: string, spaceId: strin claimsPropertyId: DEBATE_CLAIMS_PROPERTY_ID, spaceId, namePropertyId: NAME_PROPERTY_ID, + markdownPropertyId: MARKDOWN_CONTENT_PROPERTY_ID, + offsetPropertyIds: [CLAIM_START_OFFSET_PROPERTY_ID, CLAIM_END_OFFSET_PROPERTY_ID], }, signal, }); diff --git a/docs/plans/2026-09-15-debate-claim-timecodes-matcher.py b/docs/plans/2026-09-15-debate-claim-timecodes-matcher.py new file mode 100644 index 0000000000..adf20cc6a5 --- /dev/null +++ b/docs/plans/2026-09-15-debate-claim-timecodes-matcher.py @@ -0,0 +1,203 @@ +"""Align each published claim to the moment it was said, using the Whisper transcript. + +Two stages: + 1. Block -> time window. A transcript block's markdown is the concatenation of consecutive + same-speaker Whisper segments, so the block's window is found by locating that run of + segments. Exact, not fuzzy. + 2. Claim -> time window inside its block. The claim sentence is an extractor paraphrase, so + this is a content-word containment score over every window of consecutive segments in the + block, with the best window winning and reporting a confidence. +""" + +import json +import re + +STOPWORDS = { + "a", "an", "and", "are", "as", "at", "be", "been", "being", "but", "by", "can", "could", + "did", "do", "does", "for", "from", "had", "has", "have", "how", "i", "if", "in", "into", + "is", "it", "its", "of", "on", "or", "our", "s", "so", "ت", "that", "the", "their", "them", + "then", "there", "these", "they", "this", "to", "was", "we", "were", "what", "when", "which", + "who", "will", "with", "would", "you", "your", "not", "no", "more", "than", "also", "just", + "very", "much", "some", "any", "all", "out", "up", "one", "two", "about", "because", "over", +} + +MAX_WINDOW_SEGMENTS = 6 + + +def words(text): + return re.findall(r"[a-z0-9']+", text.lower()) + + +def content_words(text): + return [w for w in words(text) if w not in STOPWORDS and len(w) > 2] + + +def normalize(text): + return " ".join(words(text)) + + +def find_block_window(block_text, segments): + """The run of consecutive segments whose joined text matches the block markdown.""" + target = normalize(block_text) + if not target: + return None + + best = None + for start in range(len(segments)): + joined = "" + for end in range(start, len(segments)): + joined = (joined + " " + normalize(segments[end]["text"])).strip() + if len(joined) > len(target) + 40: + break + if joined == target: + return {"start": start, "end": end, "exact": True} + # Keep the longest prefix agreement as a fallback for punctuation drift. + if target.startswith(joined): + score = len(joined) / len(target) + if best is None or score > best["score"]: + best = {"start": start, "end": end, "exact": False, "score": score} + return best + + +def score_window(claim_words, window_text): + """Containment of the claim's content words in the window, with a length penalty.""" + window_words = set(content_words(window_text)) + if not window_words or not claim_words: + return 0.0 + hits = sum(1 for w in claim_words if w in window_words) + containment = hits / len(claim_words) + # Prefer the tightest window that achieves the containment, so a whole turn does not + # outscore the sentence inside it. + precision = hits / max(len(window_words), 1) + return containment * (0.75 + 0.25 * precision) + + +def align_claim(claim_text, segments, lo, hi): + claim_words = content_words(claim_text) + best = None + for start in range(lo, hi + 1): + for end in range(start, min(start + MAX_WINDOW_SEGMENTS, hi + 1)): + text = " ".join(segments[i]["text"] for i in range(start, end + 1)) + score = score_window(claim_words, text) + if best is None or score > best["score"]: + best = { + "score": score, + "start_ms": segments[start]["start_ms"], + "end_ms": segments[end]["end_ms"], + "start_index": start, + "end_index": end, + "text": text, + } + return best + + +def main(): + transcript = json.load(open("transcript.json")) + graph = json.load(open("graph.json")) + + segments = sorted(transcript["segments"], key=lambda s: (s["start_ms"], s["sequence_index"])) + + blocks = [] + for transcript_relation in graph["data"]["entity"]["transcripts"]: + for block_relation in transcript_relation["toEntity"]["blocks"]: + entity = block_relation["toEntity"] + blocks.append( + { + "relation_id": block_relation["id"], + "position": block_relation["position"], + "block_id": entity["id"], + "markdown": entity["markdown"][0]["text"] if entity["markdown"] else "", + "author": entity["authors"][0]["toEntity"]["id"] if entity["authors"] else None, + "claims": [ + { + "relation_id": claim["id"], + "relation_entity_id": claim["entityId"], + "claim_id": claim["toEntity"]["id"], + "position": claim["position"], + "text": (claim["toEntity"]["names"][0]["text"] if claim["toEntity"]["names"] else ""), + } + for claim in entity["claims"] + ], + } + ) + + blocks.sort(key=lambda b: b["position"] or "") + + print("=" * 96) + print("STAGE 1 — block to time window (exact match of block markdown against segment runs)") + print("=" * 96) + + for index, block in enumerate(blocks): + window = find_block_window(block["markdown"], segments) + if window is None: + block["window"] = None + print(f" [{index}] {block['block_id'][:8]} NO MATCH") + continue + lo, hi = window["start"], window["end"] + block["window"] = window + block["start_ms"] = segments[lo]["start_ms"] + block["end_ms"] = segments[hi]["end_ms"] + block["slot"] = segments[lo]["participant_slot"] + kind = "exact" if window.get("exact") else f"partial {window.get('score', 0):.0%}" + print( + f" [{index}] pos={block['position']} block={block['block_id'][:8]} " + f"slot={block['slot']} {block['start_ms'] / 1000:7.1f}s -> {block['end_ms'] / 1000:7.1f}s " + f"segs {lo}-{hi} ({kind}) claims={len(block['claims'])}" + ) + + matched = [b for b in blocks if b.get("window")] + by_time = sorted(matched, key=lambda b: b["start_ms"]) + print() + print(" relation position order :", " ".join(b["block_id"][:6] for b in blocks)) + print(" actual chronological :", " ".join(b["block_id"][:6] for b in by_time)) + print(" position order == time order:", [b["block_id"] for b in blocks] == [b["block_id"] for b in by_time]) + + print() + print("=" * 96) + print("STAGE 2 — claim to moment inside its block") + print("=" * 96) + + output = [] + for block in by_time: + lo, hi = block["window"]["start"], block["window"]["end"] + print() + print(f" block {block['block_id'][:8]} slot {block['slot']} " + f"{block['start_ms'] / 1000:.1f}s-{block['end_ms'] / 1000:.1f}s") + for claim in block["claims"]: + best = align_claim(claim["text"], segments, lo, hi) + confidence = round(best["score"], 3) if best else 0.0 + verdict = "HIGH" if confidence >= 0.55 else "LOW " if confidence >= 0.35 else "NONE" + print(f" [{verdict} {confidence:.2f}] {best['start_ms'] / 1000:6.1f}s -> {best['end_ms'] / 1000:6.1f}s " + f"{claim['text'][:72]}") + print(f" heard: \"{best['text'][:96]}\"") + output.append( + { + "claim_id": claim["claim_id"], + "claim_text": claim["text"], + "block_id": block["block_id"], + "block_claim_relation_id": claim["relation_id"], + "relation_entity_id": claim["relation_entity_id"], + "participant_slot": block["slot"], + "block_start_ms": block["start_ms"], + "block_end_ms": block["end_ms"], + "start_ms": best["start_ms"] if confidence >= 0.35 else block["start_ms"], + "end_ms": best["end_ms"] if confidence >= 0.35 else block["end_ms"], + "confidence": confidence, + "source": "segment-match" if confidence >= 0.35 else "block-window", + "matched_text": best["text"], + } + ) + + with open("alignment.json", "w") as handle: + json.dump(output, handle, indent=2) + + high = sum(1 for row in output if row["confidence"] >= 0.55) + low = sum(1 for row in output if 0.35 <= row["confidence"] < 0.55) + none = sum(1 for row in output if row["confidence"] < 0.35) + print() + print(f" {len(output)} claims — {high} high confidence, {low} low, {none} fell back to the block window") + print(" wrote alignment.json") + + +if __name__ == "__main__": + main() diff --git a/docs/plans/2026-09-15-debate-claim-timecodes-publish-instructions.md b/docs/plans/2026-09-15-debate-claim-timecodes-publish-instructions.md new file mode 100644 index 0000000000..12b75575b4 --- /dev/null +++ b/docs/plans/2026-09-15-debate-claim-timecodes-publish-instructions.md @@ -0,0 +1,279 @@ +# Publishing claim timecodes for one test debate + +**Status:** ready to run — hand this file to an agent with a funded wallet that is an editor of the space below +**Date:** 2026-09-15 +**Debate:** [Preston Mantel vs. Scot Loyd on Trump's second term is making America more authoritarian](https://www.geobrowser.io/space/4582fbbee28a16589154f7e36f1ee3c5/01a0a60772dc7cb09bf6ebba15e97b67) + +## What this is + +The debate claim UI wants to know *when* in the video each claim was said. Nothing in the graph +records that yet. This publishes it for one debate, by hand, so the UI can be built and demoed +against real data before the extractor is changed to emit it. + +The offsets below were derived by matching each published claim against the Whisper transcript +(`GET /debates/{id}/transcript?format=json` on `chat-api-testnet.geobrowser.io`, 148 segments). +Nine were matched automatically; four were read off the transcript by hand because the automatic +match was under threshold or started a few seconds early. Every one has been eyeballed against the +segment text. + +**Nothing else about the debate changes.** No claims are added, removed, renamed or reattributed. + +### Scope — read this first + +**Your job is one proposal to one space: the sections `Constants` through `Verify`.** That is 26 +values and 26 relations across the 13 claims listed below, published to +`4582fbbee28a16589154f7e36f1ee3c5`. + +Everything after `Verify` — *What the backend should do later* and *What the app should not assume* — +is background for the humans. **Do not act on it.** Do not change geo-chat, the publisher, the web +app, or any other debate. + +If anything does not match what this file says — a claim is missing, a relation entity id does not +resolve, the property data type is not `Integer`, a relation entity already holds values — **stop and +report it** rather than adapting. The ids here were read from the live graph on 2026-09-15; a +mismatch means something changed, and guessing would write wrong timecodes onto real claims. + +--- + +## Constants + +| Thing | Value | +|---|---| +| Debate space — **all 13 offset values go here** | `4582fbbee28a16589154f7e36f1ee3c5` (US Politics) | +| `Start offset` property (exists already, Integer) | `a1d1cb557b184238ba0ec78ba7f289fb` | +| `End offset` property (exists already, Integer) | `79a677b597f84ca8a1cf24eef7837b61` | +| Debate entity | `01a0a60772dc7cb09bf6ebba15e97b67` | +| Transcript entity | `6eceb1dbfefd41ff849b41ab238ae136` | + +--- + +## No new properties needed + +`Start offset` and `End offset` already exist in the **Geo** ontology space +(`a19c345ab9866679b001d7d2138d88a1`), both typed `Integer`. Use them — do not mint new ones. + +They are the right properties, not merely available ones: + +- They belong to the **`Selector`** type (`813ca865db9b486490dec6764febaab3`), whose properties are + `Start offset`, `End offset`, `Version ID` and `Target property`, and which the graph already uses + as a **relation entity type** on `Reply to`. "Offsets on a relation entity that points at a span" + is the existing idiom, and it is exactly the shape below. +- They are also published in the **Podcasts** space (`b5a31f8182b042437ede0f84ee02f104`), so media + timecodes are an intended use, not a reinterpretation. +- **Zero values exist for either property anywhere in the graph.** Nothing can break, and there is no + established unit convention to contradict. + +Two wrinkles to know about: + +1. **Unit is not declared anywhere**, so being the first writer sets the convention. This publish + writes **integer milliseconds from the start of the debate timeline** — the same origin the + player's scrubber and `turn_durations_ms` use. Record that in the app's constants and in the + geo-chat hand-off, because the value itself does not say it. (A reusable `Millisecond` unit + entity would be the durable fix; none exists today and this does not block on one.) +2. **The data type is declared twice.** The Geo space says `Integer`; the Podcasts space published a + second `Data type → Float` relation on the same entities. The API resolves `Integer` today. Write + integers. Worth cleaning up separately. + +## Write the offsets onto the relation entities + +Each relation entity gets **two values and two relations**. + +The values go on **the relation entity of the transcript-block → claim relation** — not the claim, +and not the block. A relation carries its own entity id (`Relation.entityId` in the API), distinct +from the relation id, and it currently holds nothing. That is the right home: the same claim can be +stated in two different blocks, and each statement has its own timecode. + +The two relations type that entity as a `Selector` and name what the offsets index, so "these +integers are milliseconds into the debate's video" is answerable from the data rather than from this +document: + +| Relation | Type id | Target | +|---|---|---| +| `Types` → `Selector` | `8f151ba4de204e3c9cb499ddf96f48f1` | `813ca865db9b486490dec6764febaab3` | +| `Target property` → `Debate videos` | `e1788cdf9bae42e987b0d9791de09b31` | `c48dc314fa7148aeb967139160456f1d` | + +`Selector` is the type these two offset properties belong to, and the graph already uses it this way +— as the relation entity type on `Reply to`, anchoring a reply to a span of its target. This is the +same pattern with a video instead of a text property. `Version ID`, the type's fourth property, is +deliberately left unset: there is no meaningful version to pin here. + +```ts +import { Ops } from '@geoprotocol/geo-sdk/lite'; + +const TYPES_PROPERTY = '8f151ba4de204e3c9cb499ddf96f48f1'; +const SELECTOR_TYPE = '813ca865db9b486490dec6764febaab3'; +const TARGET_PROPERTY = 'e1788cdf9bae42e987b0d9791de09b31'; +const DEBATE_VIDEOS_PROPERTY = 'c48dc314fa7148aeb967139160456f1d'; + +const WRITES = [/* the JSON below */]; + +const ops = WRITES.flatMap(write => [ + // the two timecodes + ...Ops.entities.update({ + id: write.entityId, + values: write.values.map(value => ({ + property: value.property, + type: 'integer' as const, + value: value.value, + })), + }).ops, + // this relation entity is a Selector + ...Ops.relations.create({ + fromEntity: write.entityId, + type: TYPES_PROPERTY, + toEntity: SELECTOR_TYPE, + }).ops, + // ...whose offsets index the debate's video + ...Ops.relations.create({ + fromEntity: write.entityId, + type: TARGET_PROPERTY, + toEntity: DEBATE_VIDEOS_PROPERTY, + }).ops, +]); +``` + +That is 26 values and 26 relations across the 13 claims. Publish `ops` to space +`4582fbbee28a16589154f7e36f1ee3c5`. One proposal for all of it is fine. + +This space is not optional. Values and relations are both space-scoped, and the app reads these +through a traversal already filtered to the debate's own space — anything published elsewhere is +invisible to it. + +### The writes + +| # | Relation entity id (write here) | start_ms | end_ms | Claim | How | +|---|---|---:|---:|---|---| +| 1 | `5cb095eaea774cb4ae308f4e3cc5cd2f` | 16680 | 21900 | Donald Trump is leveraging powers that have always existed within the U.S. government | matched | +| 2 | `93daa306d82e4ca5a458472bad063c8a` | 22480 | 31440 | The use of presidential executive orders has been an accelerating trend through many administrations prior to Donald Trump's | hand-checked | +| 3 | `f592a866a110475ab6804b3809b39b7c` | 40000 | 47800 | The United States is more of an oligarchy or a corporate-run government than an authoritarian state | matched | +| 4 | `a0e6129c4f0b4345ae3f692b2f199258` | 47800 | 56400 | Corporate lobbying of the U.S. government is a significant source of political issues | hand-checked | +| 5 | `a6da668a90d046868a41874c78a0c2a2` | 72640 | 84610 | The founding fathers of the United States intended to create a limited presidency with checks and balances | matched | +| 6 | `1fb00d7af22e44bbb7063a2941b9290b` | 101440 | 106520 | Policies established by a president's executive orders can be undone by a subsequent president's new executive orders | matched | +| 7 | `af6303b57b764352bb6425b70ee68352` | 110360 | 118160 | Achieving lasting political change requires passing legislation through Congress rather than relying on executive orders | hand-checked | +| 8 | `6bb34f0d87294b38b64c736909ef079c` | 134600 | 143140 | The Supreme Court is no longer providing sufficient checks on executive power | matched | +| 9 | `2ee88e8e840b43328f06774fc799590b` | 140960 | 150520 | Executive branches of the government are sidestepping the powers of Congress by suing companies directly | matched | +| 10 | `7deb967ca61e480fbb80847a83b5571a` | 150520 | 160640 | The SEC under Gary Gensler has taken action against the crypto industry, resulting in legal battles | matched | +| 11 | `77cfa2f31c854ff9bf12fa3f9ac4e29d` | 176760 | 187320 | The increasing use of executive orders by presidents is establishing a more authoritarian form of government in the U.S. | matched | +| 12 | `d259997fca4349dab0cf164860ab866b` | 195880 | 206280 | Donald Trump's second term is making America more authoritarian, amplifying a trend from his first term | matched | +| 13 | `755f5690d3f1403680af9c94d5642e1a` | 263730 | 270000 | American voters can elect a new Congress to serve as a check and balance on presidential power | hand-checked | + +```json +[ + { "entityId": "5cb095eaea774cb4ae308f4e3cc5cd2f", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 16680 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 21900 }] }, + { "entityId": "93daa306d82e4ca5a458472bad063c8a", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 22480 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 31440 }] }, + { "entityId": "f592a866a110475ab6804b3809b39b7c", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 40000 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 47800 }] }, + { "entityId": "a0e6129c4f0b4345ae3f692b2f199258", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 47800 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 56400 }] }, + { "entityId": "a6da668a90d046868a41874c78a0c2a2", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 72640 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 84610 }] }, + { "entityId": "1fb00d7af22e44bbb7063a2941b9290b", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 101440 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 106520 }] }, + { "entityId": "af6303b57b764352bb6425b70ee68352", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 110360 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 118160 }] }, + { "entityId": "6bb34f0d87294b38b64c736909ef079c", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 134600 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 143140 }] }, + { "entityId": "2ee88e8e840b43328f06774fc799590b", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 140960 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 150520 }] }, + { "entityId": "7deb967ca61e480fbb80847a83b5571a", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 150520 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 160640 }] }, + { "entityId": "77cfa2f31c854ff9bf12fa3f9ac4e29d", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 176760 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 187320 }] }, + { "entityId": "d259997fca4349dab0cf164860ab866b", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 195880 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 206280 }] }, + { "entityId": "755f5690d3f1403680af9c94d5642e1a", "values": [ + { "property": "a1d1cb557b184238ba0ec78ba7f289fb", "type": "integer", "value": 263730 }, + { "property": "79a677b597f84ca8a1cf24eef7837b61", "type": "integer", "value": 270000 }] } +] +``` + +## Verify + +Once indexed, this returns 13 relations, each carrying two integer values and two relations: + +```graphql +query Verify { + entity(id: "01a0a60772dc7cb09bf6ebba15e97b67") { + transcripts: relationsList( + filter: { typeId: { is: "c504c7d5c3374016a5f083e4b5a92911" }, spaceId: { is: "4582fbbee28a16589154f7e36f1ee3c5" } } + ) { + toEntity { + blocks: relationsList( + filter: { typeId: { is: "beaba5cba67741a8b35377030613fc70" }, spaceId: { is: "4582fbbee28a16589154f7e36f1ee3c5" } } + ) { + toEntity { + claims: relationsList( + filter: { typeId: { is: "e614cce1c4ce45868304fd1237119eb2" }, spaceId: { is: "4582fbbee28a16589154f7e36f1ee3c5" } } + ) { + entityId + entity { + valuesList { propertyId integer } + relationsList(filter: { spaceId: { is: "4582fbbee28a16589154f7e36f1ee3c5" } }) { + type { name } + toEntity { id name } + } + } + toEntity { name } + } + } + } + } + } + } +} +``` + +Sanity checks: every `start_ms` < its `end_ms`; every value between 0 and 270000 (the debate is +4m30s); every relation entity carries `Types → Selector` and `Target property → Debate videos`; and +the 13 claims in ascending `start_ms` order should read as a coherent argument — Trump's powers, +executive orders, oligarchy, lobbying (speaker 1), then founding fathers, executive orders undone, +legislation through Congress (speaker 2), and so on. + +--- + +## What the backend should do later + +> Background, not instructions. Nothing below is part of the publish above. + +This file publishes offsets for one debate by hand. The durable version: + +1. `GET /debates/{id}/claims` gains `start_ms` and `end_ms` per extracted claim. The extractor + knows which transcript span it extracted each claim from, so it can report the span exactly + rather than having it recovered by text matching afterwards. +2. `decodeExtractedClaims` in `apps/web/core/debates/server/extracted-claims.ts` carries them onto + `DebateClaimInput`. +3. `debate-publish-draft.ts` writes them onto the block → claim relation entity it already creates, + using `Start offset` and `End offset`, in integer milliseconds — and types that entity + `Types → Selector` with `Target property → Debate videos`, as this publish does. The relation and + its entity are minted in the same `relate()` call, so this is a small change there rather than a + second pass. +4. Optionally, a backfill sweep over already-published debates using the same matcher. + +Until then the app falls back to matching client-side, so the UI works on every debate either way. + +## What the app should not assume + +> Background, not instructions. This is for whoever builds the UI. + +Two things this investigation turned up, both of which the UI has to handle: + +- **Relation `position` is not transcript order.** `debate-publish-draft.ts` assigns every relation + `Position.generate()`, which is random, not monotonic. On this debate the blocks in position order + are the 3rd, 1st, 6th, 4th, 5th and 2nd turns. Order blocks by their timecodes (or by matching + block text against the transcript), never by position. +- **Blocks with empty text are filtered out at publish**, so block count can be less than turn count + and block index does not map to turn index. From 3951494d8938e4a6a5c524d50a4e829f676261dd Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 13:32:00 -0700 Subject: [PATCH 02/71] feat(debates): surface claims over the video as they are said, with pills (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claims a debate produces are its payload, and they lived behind a button in a side panel with no relation to what was on screen. This puts them where the attention already is. Three things land on the browse player: - A claim card rises in the seam between the two tiles as the claim is said, with the same Agree/Disagree pills, the same vocabulary and the same publish path as every other claim surface. One at a time; the most recently started wins when windows overlap, so the card tracks the argument rather than lagging behind it. Dismissible, and it never pauses the video or opens a dialog of its own. - Markers on the scrubber for every precisely-placed claim, each one a seek. - A card at the end saying how many claims the viewer took a position on, then offering the ones they skipped, one at a time. It does not draw the winner vote, which already lives on the tiles and is visible at the same moment. The crowd split is withheld until the viewer answers. Showing it first biases the answer and makes the tally a measure of itself; withholding it is also what makes answering worth anything. Only claims the resolver placed confidently are eligible to appear over the video. A card asserts "they are saying this now", and being wrong about that misquotes a real person — so a roughly-placed claim stays in the panel, where a list makes no claim about when. Whole-turn fallbacks get no scrubber marker either: the middle of a thirty-second window is not a place to jump to. Both reads are shared cache entries rather than new requests — the claims come back on the key the count badge and the panel already use, and the transcript on the key the player already fetches for subtitles. `scripts/verify-claim-timing.ts` runs the whole pipeline against the live testnet debate, which is how this was checked end to end: 13 of 13 claims placed, in spoken order, every one eligible for the live layer. --- .../browse/debate-claim-ticker.test.tsx | 169 +++++++++ .../debates/browse/debate-claim-ticker.tsx | 329 ++++++++++++++++++ .../debates/browse/debate-feed-player.tsx | 36 ++ .../core/debates/browse/debate-scorecard.tsx | 87 +++++ apps/web/core/debates/claim-ticker.test.ts | 142 ++++++++ apps/web/core/debates/claim-ticker.ts | 101 ++++++ apps/web/scripts/verify-claim-timing.ts | 126 +++++++ 7 files changed, 990 insertions(+) create mode 100644 apps/web/core/debates/browse/debate-claim-ticker.test.tsx create mode 100644 apps/web/core/debates/browse/debate-claim-ticker.tsx create mode 100644 apps/web/core/debates/browse/debate-scorecard.tsx create mode 100644 apps/web/core/debates/claim-ticker.test.ts create mode 100644 apps/web/core/debates/claim-ticker.ts create mode 100644 apps/web/scripts/verify-claim-timing.ts diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx new file mode 100644 index 0000000000..0f539e1f84 --- /dev/null +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -0,0 +1,169 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TickerWindow } from '~/core/debates/claim-ticker'; +import type { TimedClaim } from '~/core/debates/claim-timing'; + +import { DebateClaimTickerCard } from './debate-claim-ticker'; + +const CLAIM_SPACE = '52c7ae149838b6d47ce0f3b2a5974546'; + +const mocks = vi.hoisted(() => ({ + /** What the shared position control reports the viewer currently holds. */ + viewerPosition: null as string | null, + respond: vi.fn(), +})); + +vi.mock('~/core/claims/browse/claim-summary', () => ({ + ClaimSummary: () =>
, +})); + +vi.mock('~/core/claims/browse/use-claim-response-state', () => ({ + useClaimResponseState: () => ({ + responseKind: 'stance', + isResponseKindResolved: true, + isViewerResponseResolved: true, + responseBlockedReason: null, + summary: { isLoading: false }, + claim: { id: 'claim-1' }, + positions: [], + readiness: {}, + }), +})); + +vi.mock('~/core/debates/matchmaking/matchmaking-claim-card', () => ({ + PositionRow: ({ onRespond, disabled }: { onRespond: (side: string) => void; disabled?: boolean }) => ( + + ), + useClaimPositionControl: () => ({ + viewerPosition: mocks.viewerPosition, + optimisticPositions: [], + respond: mocks.respond, + actionTitle: () => '', + responseError: null, + canRespond: true, + }), +})); + +vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() })); + +function claim(overrides: Partial = {}): TimedClaim { + return { + id: 'claim-1', + text: 'The Supreme Court is no longer providing sufficient checks on executive power', + spaceId: CLAIM_SPACE, + blockId: 'block-1', + publishedTiming: null, + timing: { startMs: 134_600, endMs: 143_140, confidence: 1, source: 'published' }, + ...overrides, + }; +} + +function window(overrides: Partial = {}): TickerWindow { + return { claim: claim(overrides), startMs: 134_600, endMs: 148_140 }; +} + +function renderCard(props: Partial> = {}) { + return render( + + ); +} + +beforeEach(() => { + mocks.viewerPosition = null; + mocks.respond.mockClear(); +}); + +afterEach(cleanup); + +describe('DebateClaimTickerCard', () => { + it('names the debater and quotes the claim', () => { + renderCard(); + + expect(screen.getByText('Preston Mantel just said')).toBeInTheDocument(); + expect(screen.getByText(/Supreme Court is no longer providing/)).toBeInTheDocument(); + }); + + // Showing the split first biases the answer, which makes the tally a measure of itself. It is + // also the payoff for answering, so it has to be withheld to be worth anything. + it('withholds the crowd split until the viewer has taken a side', () => { + renderCard(); + + expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); + }); + + it('reveals the crowd split once the viewer has answered', () => { + mocks.viewerPosition = 'positive'; + + renderCard(); + + expect(screen.getByTestId('crowd-split')).toBeInTheDocument(); + }); + + it('reports the answer up so the card is not asked again on a rewind', () => { + mocks.viewerPosition = 'positive'; + const onAnswered = vi.fn(); + + renderCard({ onAnswered }); + + expect(onAnswered).toHaveBeenCalledExactlyOnceWith('claim-1'); + }); + + it('does not report an answer for a claim the viewer has not taken a side on', () => { + const onAnswered = vi.fn(); + + renderCard({ onAnswered }); + + expect(onAnswered).not.toHaveBeenCalled(); + }); + + it('can be waved away without answering', () => { + const onDismiss = vi.fn(); + + renderCard({ onDismiss }); + fireEvent.click(screen.getByLabelText('Dismiss claim')); + + expect(onDismiss).toHaveBeenCalledExactlyOnceWith('claim-1'); + }); + + // The video behind the card is one large play/pause button. + it('does not toggle playback when the card itself is clicked', () => { + const onToggle = vi.fn(); + + render( + + ); + fireEvent.click(screen.getByText(/Supreme Court is no longer providing/)); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + // A claim the graph reports no space for cannot be linked or responded to, so the panel renders + // it as plain text. Over the video there is nothing useful to draw at all. + it('draws nothing for a claim with no space to publish a response to', () => { + const { container } = renderCard({ window: { ...window({ spaceId: null }) } }); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx new file mode 100644 index 0000000000..16a2c79129 --- /dev/null +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -0,0 +1,329 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { ClaimSummary } from '~/core/claims/browse/claim-summary'; +import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; +import type { Debate, DebateClaim } from '~/core/debates/api'; +import { type ClaimMarker, type TickerWindow, activeTickerClaim, claimMarkers, tickerWindows } from '~/core/debates/claim-ticker'; +import { type TimedClaim, claimsInSpokenOrder } from '~/core/debates/claim-timing'; +import { useDebateClaimsBySpaces } from '~/core/debates/hooks'; +import { PositionRow, useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; +import { useClaimTimings } from '~/core/debates/use-claim-timings'; +import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; +import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; +import { uuidToHex } from '~/core/id/normalize'; +import { useQueryEntities } from '~/core/sync/use-store'; +import type { Entity } from '~/core/types'; + +import { Close } from '~/design-system/icons/close'; +import { Text } from '~/design-system/text'; + +export type DebateTicker = { + /** The claim to draw over the video right now, or null. */ + active: TickerWindow | null; + /** Every precisely-placed claim, for the scrubber. */ + markers: ClaimMarker[]; + /** Claims in the order they were said, for the card at the end. */ + claims: TimedClaim[]; + /** Claim ids the viewer has answered this session. */ + answered: ReadonlySet; + onAnswered: (claimId: string) => void; + onDismiss: (claimId: string) => void; + /** Per-claim lookups, hoisted so the card and the end-of-debate stack share one batch. */ + rowsByClaimId: Map; + entitiesByClaimId: Map; + /** Claim id → the debater who said it, for the card's "Ana just said". */ + speakerByClaimId: Map; +}; + +/** + * Everything the live claim layer needs for one debate. + * + * Both reads are shared cache entries rather than new requests: the claims come back on the same + * key the feed's count badge and the Claims panel already use, and the transcript on the same key + * `useDebatePlayback` fetches for subtitles. On a card that is playing, this costs nothing. + */ +export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled: boolean): DebateTicker { + const { claims } = useDebateTranscriptClaims(debate.id, debate.claim.space_id, enabled); + const { timings } = useClaimTimings(debate.id, claims, enabled); + + const [answered, setAnswered] = React.useState>(() => new Set()); + const [dismissed, setDismissed] = React.useState>(() => new Set()); + + const onAnswered = React.useCallback((claimId: string) => { + setAnswered(current => new Set(current).add(claimId)); + setDismissed(current => new Set(current).add(claimId)); + }, []); + + const onDismiss = React.useCallback((claimId: string) => { + setDismissed(current => new Set(current).add(claimId)); + }, []); + + const timedClaims = React.useMemo(() => claimsInSpokenOrder(claims.all, timings), [claims.all, timings]); + const windows = React.useMemo(() => tickerWindows(timedClaims), [timedClaims]); + const active = React.useMemo( + () => (enabled ? activeTickerClaim(windows, playheadMs, dismissed) : null), + [enabled, windows, playheadMs, dismissed] + ); + + // The debate's own length rather than the video element's: the two recordings are composited + // against the debate timeline, which is what the scrubber measures. + const durationMs = React.useMemo( + () => claims.all.reduce((longest, claim) => Math.max(longest, timings.get(claim.id)?.endMs ?? 0), 0), + [claims.all, timings] + ); + const markers = React.useMemo(() => claimMarkers(timedClaims, durationMs), [timedClaims, durationMs]); + + // One batch for every claim, the way the panel does it, so the live card and the end-of-debate + // stack never issue a lookup per claim as they mount. + const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); + const { entities } = useQueryEntities({ + where: { id: { in: claimIds } }, + first: claimIds.length || 1, + enabled: enabled && claimIds.length > 0, + }); + const entitiesByClaimId = React.useMemo(() => { + const map = new Map(); + for (const entity of entities) map.set(entity.id, entity); + return map; + }, [entities]); + + const rowGroups = React.useMemo(() => { + const bySpace = new Map(); + for (const claim of claims.all) { + if (!claim.spaceId) continue; + const ids = bySpace.get(claim.spaceId); + if (ids) ids.push(claim.id); + else bySpace.set(claim.spaceId, [claim.id]); + } + return [...bySpace].map(([spaceId, ids]) => ({ spaceId, claimIds: ids })); + }, [claims.all]); + + const rowsQuery = useDebateClaimsBySpaces(rowGroups); + const rowsByClaimId = React.useMemo(() => { + const map = new Map(); + for (const row of rowsQuery.claims) map.set(row.claim_entity_id, row); + return map; + }, [rowsQuery.claims]); + + // Attribution rides the *block*, not the claim: a claim's own space is the debate's publication + // space, which both debaters share. The block's `Authors` relation points at the speaker's + // personal space, which is the id the participant list keys on. + const speakerByClaimId = React.useMemo(() => { + const labelBySpace = new Map(); + for (const participant of orderedParticipants(debate)) { + labelBySpace.set(uuidToHex(participant.profile_space_id), speakerLabel(participant)); + } + + const labelByBlock = new Map(); + for (const block of claims.blocks) { + const label = block.authorSpaceId ? labelBySpace.get(uuidToHex(block.authorSpaceId)) : undefined; + if (label) labelByBlock.set(block.id, label); + } + + const byClaim = new Map(); + for (const claim of claims.all) { + const label = labelByBlock.get(claim.blockId); + if (label) byClaim.set(claim.id, label); + } + return byClaim; + }, [claims.all, claims.blocks, debate]); + + return { + active, + markers, + claims: timedClaims, + answered, + onAnswered, + onDismiss, + rowsByClaimId, + entitiesByClaimId, + speakerByClaimId, + }; +} + +/** + * The claim card that rises over the video as it is said. + * + * Sits in the seam between the two tiles, which is the one strip of the player that is never a + * face. One card at a time, dismissible, and it never pauses the video or opens a dialog — the + * only thing that interrupts playback is the sign-in prompt, which is the app's standard prompt + * and only appears if the viewer presses a pill while signed out. + */ +export function DebateClaimTickerCard({ + window, + speaker, + row, + entity, + onAnswered, + onDismiss, +}: { + window: TickerWindow; + speaker: string | null; + row: DebateClaim | null; + entity: Entity | null; + onAnswered: (claimId: string) => void; + onDismiss: (claimId: string) => void; +}) { + const { claim } = window; + + if (!claim.spaceId) return null; + + return ( +
event.stopPropagation()} + className="pointer-events-auto w-full max-w-[26rem] rounded-lg bg-white p-3 shadow-card" + > +
+
+ + {speaker ? `${speaker} just said` : 'Just said'} + + + {claim.text} + +
+ +
+ + +
+ ); +} + + +/** + * The same pills, the same publish path and the same vocabulary as every other claim surface. + * + * The crowd split is deliberately withheld until the viewer has answered. Showing it first biases + * the answer and makes the tally a measure of itself; withholding it also gives the tap a payoff. + */ +function TickerClaimControls({ + claimId, + spaceId, + row, + entity, + onAnswered, +}: { + claimId: string; + spaceId: string; + row: DebateClaim | null; + entity: Entity | null; + onAnswered: (claimId: string) => void; +}) { + const promptSignIn = usePrivySignIn(); + const { + responseKind, + isResponseKindResolved, + isViewerResponseResolved, + responseBlockedReason, + summary, + claim, + positions, + readiness, + } = useClaimResponseState({ claimId, spaceId, row, entity }); + + const control = useClaimPositionControl({ + claim, + positions, + readiness, + answersReady: isResponseKindResolved && isViewerResponseResolved, + responseBlockedReason, + onRequireSignIn: promptSignIn, + // Same reason the panel passes false: the viewer is already watching this debate, so offering + // them another one is the wrong invitation at the wrong moment. + offersDebate: false, + }); + + const answered = control.viewerPosition !== null; + + // Reported once, on the transition. The card stays up for the rest of its window so the viewer + // sees the split they just earned; it is the *next* seek past it that will skip it. + const reported = React.useRef(false); + React.useEffect(() => { + if (answered && !reported.current) { + reported.current = true; + onAnswered(claimId); + } + }, [answered, claimId, onAnswered]); + + return ( +
+ + {control.responseError ? ( +
+ + {control.responseError} + +
+ ) : null} + {answered && !summary.isLoading ? ( + + ) : null} +
+ ); +} + +/** Claim markers on the scrubber, each a place the viewer can jump to. */ +export function ClaimScrubberMarkers({ + markers, + onSeek, + className, +}: { + markers: ClaimMarker[]; + onSeek: (ms: number) => void; + className?: string; +}) { + if (markers.length === 0) return null; + + return ( +
+ {markers.map(marker => ( +
+ ); +} diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 4d18c52596..d7ba94b1ba 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -18,8 +18,11 @@ import { Avatar } from '~/design-system/avatar'; import { RetrySmall } from '~/design-system/icons/retry-small'; import { Text } from '~/design-system/text'; +import { ClaimScrubberMarkers, DebateClaimTickerCard, useDebateClaimTicker } from './debate-claim-ticker'; +import { DebateScorecard } from './debate-scorecard'; import { Play, Speaker, SpeakerMuted } from './icons'; import { WinnerVoteButton } from './winner-vote-button'; +import type { ClaimMarker } from '~/core/debates/claim-ticker'; type DebateFeedPlayerProps = { debate: Debate; @@ -95,6 +98,10 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } }, [active, isScrubbing, playbackEnded, playing, ready, resumeBoth, suspend, userPaused]); + // The live claim layer. Loaded alongside the recordings so a card is ready the moment the claim + // it belongs to is spoken, rather than appearing a beat late on the first one. + const ticker = useDebateClaimTicker(debate, playheadSeconds * 1000, active || preload); + const showControls = ready && (userPaused || (playbackEnded && !hasVoted)); // End of an unvoted debate offers a replay; a user pause shows the paused glyph. const showReplay = ready && playbackEnded && !hasVoted; @@ -167,6 +174,7 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb + {/* The seam between the two tiles is the one strip of the player that is never a face, so + the claim card and the end-of-debate card both sit there. `pointer-events-none` on the + positioner keeps the rest of the seam clickable for play/pause. */} + {ready && !playbackEnded && ticker.active && ( +
+ +
+ )} + + {ready && playbackEnded && ( +
+ +
+ )} + {showPausedGlyph && ( +
+ ); +} diff --git a/apps/web/core/debates/claim-ticker.test.ts b/apps/web/core/debates/claim-ticker.test.ts new file mode 100644 index 0000000000..5bf85bd939 --- /dev/null +++ b/apps/web/core/debates/claim-ticker.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import type { ClaimTiming, TimedClaim } from './claim-timing'; +import { CLAIM_LINGER_MS, activeTickerClaim, claimMarkers, scoreDebate, tickerWindows } from './claim-ticker'; + +function timed(id: string, timing: ClaimTiming | null, text = `Claim ${id}`): TimedClaim { + return { id, text, spaceId: 'space-1', blockId: 'block-1', publishedTiming: null, timing }; +} + +const confident = (startMs: number, endMs: number): ClaimTiming => ({ + startMs, + endMs, + confidence: 1, + source: 'published', +}); + +/** Matched, but not well enough to assert over the video. */ +const unsure = (startMs: number, endMs: number): ClaimTiming => ({ + startMs, + endMs, + confidence: 0.4, + source: 'segment', +}); + +const wholeTurn = (startMs: number, endMs: number): ClaimTiming => ({ + startMs, + endMs, + confidence: 0, + source: 'block', +}); + +describe('tickerWindows', () => { + it('keeps a card up for a beat after the claim finishes, so it can be read and answered', () => { + const [window] = tickerWindows([timed('a', confident(10_000, 14_000))]); + + expect(window.startMs).toBe(10_000); + expect(window.endMs).toBe(14_000 + CLAIM_LINGER_MS); + }); + + it('caps a very long claim rather than leaving a card up over the next one', () => { + const [window] = tickerWindows([timed('a', confident(0, 30_000))]); + + expect(window.endMs).toBe(12_000); + }); + + // The live layer asserts "they are saying this now". A claim the matcher placed roughly is fine + // in the panel and a misquote over the video. + it('excludes claims the matcher was not confident about, and whole-turn fallbacks', () => { + const windows = tickerWindows([ + timed('sure', confident(1_000, 2_000)), + timed('unsure', unsure(3_000, 4_000)), + timed('turn', wholeTurn(0, 30_000)), + timed('untimed', null), + ]); + + expect(windows.map(window => window.claim.id)).toEqual(['sure']); + }); +}); + +describe('activeTickerClaim', () => { + const windows = tickerWindows([ + timed('first', confident(10_000, 14_000)), + timed('second', confident(18_000, 22_000)), + ]); + + it('shows nothing before the first claim is said', () => { + expect(activeTickerClaim(windows, 5_000)).toBeNull(); + }); + + it('shows the claim being said', () => { + expect(activeTickerClaim(windows, 12_000)?.claim.id).toBe('first'); + }); + + it('keeps it up through the linger, then hands over to the next claim', () => { + expect(activeTickerClaim(windows, 18_000 - 1)?.claim.id).toBe('first'); + // The first claim's linger runs to 19s, but the second starts at 18s and takes over. + expect(activeTickerClaim(windows, 14_000 + CLAIM_LINGER_MS)?.claim.id).toBe('second'); + }); + + it('lets a claim go once its linger expires with nothing after it', () => { + const lone = tickerWindows([timed('only', confident(10_000, 14_000))]); + + expect(activeTickerClaim(lone, 14_000 + CLAIM_LINGER_MS - 1)?.claim.id).toBe('only'); + expect(activeTickerClaim(lone, 14_000 + CLAIM_LINGER_MS)).toBeNull(); + }); + + it('follows the debate when two claims overlap, rather than lagging on the older one', () => { + const overlapping = tickerWindows([ + timed('earlier', confident(10_000, 20_000)), + timed('later', confident(14_000, 18_000)), + ]); + + expect(activeTickerClaim(overlapping, 12_000)?.claim.id).toBe('earlier'); + expect(activeTickerClaim(overlapping, 15_000)?.claim.id).toBe('later'); + }); + + // Seeking backwards over a claim you already answered should not ask again. + it('does not re-ask a claim the viewer has answered or dismissed', () => { + expect(activeTickerClaim(windows, 12_000, new Set(['first']))).toBeNull(); + }); + + it('falls through to another live claim when the top one is dismissed', () => { + const overlapping = tickerWindows([ + timed('earlier', confident(10_000, 20_000)), + timed('later', confident(14_000, 18_000)), + ]); + + expect(activeTickerClaim(overlapping, 15_000, new Set(['later']))?.claim.id).toBe('earlier'); + }); +}); + +describe('claimMarkers', () => { + it('places every precisely-timed claim on the scrubber', () => { + const markers = claimMarkers( + [timed('b', confident(135_000, 143_000)), timed('a', confident(27_000, 31_000))], + 270_000 + ); + + expect(markers.map(marker => marker.id)).toEqual(['a', 'b']); + expect(markers[0].fraction).toBeCloseTo(0.1); + }); + + // A marker is a place to jump to. The middle of a 30s turn is not a place. + it('leaves out claims only known to their turn', () => { + expect(claimMarkers([timed('turn', wholeTurn(0, 30_000))], 270_000)).toEqual([]); + }); + + it('draws nothing before the duration is known', () => { + expect(claimMarkers([timed('a', confident(1_000, 2_000))], 0)).toEqual([]); + }); +}); + +describe('scoreDebate', () => { + it('splits what the viewer answered from what they let go by', () => { + const claims = [timed('a', confident(1_000, 2_000)), timed('b', confident(3_000, 4_000))]; + + const score = scoreDebate(claims, new Set(['a'])); + + expect(score.answered).toEqual(['a']); + expect(score.unanswered.map(claim => claim.id)).toEqual(['b']); + }); +}); diff --git a/apps/web/core/debates/claim-ticker.ts b/apps/web/core/debates/claim-ticker.ts new file mode 100644 index 0000000000..92db09a772 --- /dev/null +++ b/apps/web/core/debates/claim-ticker.ts @@ -0,0 +1,101 @@ +import { LIVE_TIMING_CONFIDENCE, type TimedClaim } from './claim-timing'; + +/** + * How long a claim card stays up after the debater finishes saying it. + * + * Long enough to read a sentence and press a pill, short enough that the next claim is not queuing + * behind it. Turns in the default format run 30s and produce two or three claims, so a linger much + * longer than this starts stacking. + */ +export const CLAIM_LINGER_MS = 5_000; + +/** A card is never up for longer than this, however long the claim itself ran. */ +const MAX_VISIBLE_MS = 12_000; + +export type TickerWindow = { claim: TimedClaim; startMs: number; endMs: number }; + +/** + * The claims eligible to surface over the video, with the window each is visible for. + * + * Only confidently-placed claims qualify. A claim the matcher put in roughly the right region is + * fine in a list and not fine over the video, where the card asserts "they are saying this now" — + * and being wrong about that misquotes a real person. Those claims still appear in the panel. + */ +export function tickerWindows(claims: TimedClaim[]): TickerWindow[] { + return claims + .filter(claim => claim.timing !== null && claim.timing.confidence >= LIVE_TIMING_CONFIDENCE) + .map(claim => { + const timing = claim.timing as NonNullable; + const endMs = Math.min(timing.endMs + CLAIM_LINGER_MS, timing.startMs + MAX_VISIBLE_MS); + return { claim, startMs: timing.startMs, endMs }; + }) + .sort((a, b) => a.startMs - b.startMs); +} + +/** + * The claim to show at this moment, or null. + * + * One card at a time, by design: two debaters talking over each other is the debate, but two cards + * is a form. When windows overlap — the test debate has a pair eight seconds apart — the most + * recently started wins, so the card always tracks what is being said now rather than lagging on + * something that has moved on. + * + * `dismissed` carries the claims the viewer has answered or waved away. They are skipped rather + * than redrawn, so seeking backwards does not re-ask a question already answered. + */ +export function activeTickerClaim( + windows: TickerWindow[], + playheadMs: number, + dismissed: ReadonlySet = new Set() +): TickerWindow | null { + let active: TickerWindow | null = null; + + for (const window of windows) { + if (dismissed.has(window.claim.id)) continue; + if (playheadMs < window.startMs || playheadMs >= window.endMs) continue; + if (active === null || window.startMs > active.startMs) active = window; + } + + return active; +} + +export type ClaimMarker = { id: string; text: string; atMs: number; fraction: number }; + +/** + * Where each claim sits on the scrubber, as a fraction of the debate's length. + * + * Every claim with a known moment gets a marker, not just the confident ones: a marker is an + * offer to jump, and landing a second or two early costs the viewer nothing. Whole-turn fallbacks + * are excluded — a marker in the middle of a 30s window points at nothing in particular. + */ +export function claimMarkers(claims: TimedClaim[], durationMs: number): ClaimMarker[] { + if (durationMs <= 0) return []; + + return claims + .filter(claim => claim.timing !== null && claim.timing.source !== 'block') + .map(claim => { + const timing = claim.timing as NonNullable; + return { + id: claim.id, + text: claim.text, + atMs: timing.startMs, + fraction: Math.max(0, Math.min(1, timing.startMs / durationMs)), + }; + }) + .sort((a, b) => a.atMs - b.atMs); +} + +/** How the viewer's answers broke down, for the card at the end. */ +export type DebateScore = { + /** Claim ids the viewer answered, in the order they answered them. */ + answered: string[]; + /** Claims with a known moment that the viewer did not answer. */ + unanswered: TimedClaim[]; +}; + +export function scoreDebate(claims: TimedClaim[], answered: ReadonlySet): DebateScore { + return { + answered: claims.filter(claim => answered.has(claim.id)).map(claim => claim.id), + unanswered: claims.filter(claim => !answered.has(claim.id)), + }; +} diff --git a/apps/web/scripts/verify-claim-timing.ts b/apps/web/scripts/verify-claim-timing.ts new file mode 100644 index 0000000000..3a0ac2dc7b --- /dev/null +++ b/apps/web/scripts/verify-claim-timing.ts @@ -0,0 +1,126 @@ +/** + * End-to-end check of the claim timing resolver against the live testnet debate. + * + * Runs the real grouping, the real resolver and the real ticker selection over the real published + * data, so the answer is what the UI will draw rather than what a fixture says. + * + * Usage: bun scripts/verify-claim-timing.ts + */ +import { activeTickerClaim, claimMarkers, tickerWindows } from '../core/debates/claim-ticker'; +import { claimsInSpokenOrder, formatTimecode, resolveClaimTimings } from '../core/debates/claim-timing'; +import { + AUTHORS_PROPERTY_ID, + BLOCKS_PROPERTY_ID, + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, + DEBATE_CLAIMS_PROPERTY_ID, + DEBATE_TRANSCRIPTS_PROPERTY_ID, + MARKDOWN_CONTENT_PROPERTY_ID, + NAME_PROPERTY_ID, +} from '../core/debates/ontology'; +import { groupTranscriptClaims } from '../core/debates/transcript-claims'; + +const API = 'https://api-testnet.geobrowser.io/graphql'; +const CHAT = 'https://chat-api-testnet.geobrowser.io'; +const DEBATE_ENTITY = '01a0a60772dc7cb09bf6ebba15e97b67'; +const DEBATE_ID = '01a0a607-72dc-7cb0-9bf6-ebba15e97b67'; +const SPACE = '4582fbbee28a16589154f7e36f1ee3c5'; + +const QUERY = ` +query DebateTranscriptClaims( + $id: UUID!, $transcriptsPropertyId: UUID!, $blocksPropertyId: UUID!, $authorsPropertyId: UUID!, + $claimsPropertyId: UUID!, $spaceId: UUID!, $namePropertyId: UUID!, $markdownPropertyId: UUID!, + $offsetPropertyIds: [UUID!] +) { + entity(id: $id) { + transcripts: relationsList(filter: { typeId: { is: $transcriptsPropertyId }, spaceId: { is: $spaceId } }) { + position + toEntity { + id + blocks: relationsList(filter: { typeId: { is: $blocksPropertyId }, spaceId: { is: $spaceId } }) { + position + toEntity { + id + markdown: valuesList(filter: { propertyId: { is: $markdownPropertyId } }) { spaceId text } + authors: relationsList(filter: { typeId: { is: $authorsPropertyId }, spaceId: { is: $spaceId } }) { + toEntity { id } + } + claims: relationsList(filter: { typeId: { is: $claimsPropertyId }, spaceId: { is: $spaceId } }) { + position + entity { + valuesList(filter: { propertyId: { in: $offsetPropertyIds }, spaceId: { is: $spaceId } }) { + propertyId + integer + } + } + toEntity { + id + name + spaceIds + names: valuesList(filter: { propertyId: { is: $namePropertyId } }) { spaceId text } + } + } + } + } + } + } + } +}`; + +const response = await fetch(API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: QUERY, + variables: { + id: DEBATE_ENTITY, + transcriptsPropertyId: DEBATE_TRANSCRIPTS_PROPERTY_ID, + blocksPropertyId: BLOCKS_PROPERTY_ID, + authorsPropertyId: AUTHORS_PROPERTY_ID, + claimsPropertyId: DEBATE_CLAIMS_PROPERTY_ID, + spaceId: SPACE, + namePropertyId: NAME_PROPERTY_ID, + markdownPropertyId: MARKDOWN_CONTENT_PROPERTY_ID, + offsetPropertyIds: [CLAIM_START_OFFSET_PROPERTY_ID, CLAIM_END_OFFSET_PROPERTY_ID], + }, + }), +}); + +const body = await response.json(); +if (body.errors) { + console.error(JSON.stringify(body.errors, null, 2)); + process.exit(1); +} + +const claims = groupTranscriptClaims(body.data, SPACE); +console.log(`claims: ${claims.totalCount} blocks: ${claims.blocks.length}`); +console.log(`with published timecodes: ${claims.all.filter(claim => claim.publishedTiming !== null).length}`); + +const transcript = await fetch(`${CHAT}/debates/${DEBATE_ID}/transcript?format=json`).then(value => value.json()); +console.log(`transcript segments: ${transcript.segments.length}`); + +const timings = resolveClaimTimings({ claims: claims.all, blocks: claims.blocks, segments: transcript.segments }); +const ordered = claimsInSpokenOrder(claims.all, timings); + +console.log('\nAs the UI will order and label them:'); +for (const claim of ordered) { + const timing = claim.timing; + const at = timing ? formatTimecode(timing.startMs) : ' — '; + const source = timing ? timing.source : 'none'; + console.log(` ${at.padStart(5)} [${source.padEnd(9)}] ${claim.text.slice(0, 66)}`); +} + +const windows = tickerWindows(ordered); +console.log(`\nticker-eligible claims: ${windows.length} of ${ordered.length}`); + +const markers = claimMarkers(ordered, 270_000); +console.log(`scrubber markers: ${markers.length}`); + +console.log('\nWhat the viewer sees, sampled every 10s:'); +for (let ms = 0; ms <= 270_000; ms += 10_000) { + const active = activeTickerClaim(windows, ms); + if (active) console.log(` ${formatTimecode(ms).padStart(5)} ${active.claim.text.slice(0, 62)}`); +} + +const missing = ordered.filter(claim => claim.timing === null); +console.log(`\nclaims with no moment at all: ${missing.length}`); From e6829c117603ec66f3006d88f34bdb8215eb3d50 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 13:45:09 -0700 Subject: [PATCH 03/71] test(debates): keep the feed-player suites in step with the claim ticker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ticker adds two lookups to the player, and two suites that render the feed stub the modules they come from. One needed the new row lookup; the other was building an empty claims object by hand, which had to grow a field it did not know about — it now imports the real empty value so it cannot drift again. --- .../debates/debates-page-client.test.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx index e4d9ecf0e6..486d26c798 100644 --- a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx @@ -59,6 +59,9 @@ vi.mock('~/core/debates/hooks', () => ({ useDebateMedia: () => ({ data: undefined, isLoading: false, isError: false }), useDebateTranscript: () => ({ data: { segments: [] }, isLoading: false, error: null }), useDebateClaims: () => ({ data: { claims: [] } }), + // Reached through the player's claim ticker, which asks per space for the rows behind each + // claim. This suite's debates carry no claims, so it answers with none. + useDebateClaimsBySpaces: () => ({ claims: [], isLoading: false, isError: false }), // The feed resolves an anchor by id when the space listing does not contain it (GEO-2764). // These tests never anchor, so it stays idle. useDebate: () => ({ data: null, isLoading: false, error: null }), @@ -99,13 +102,18 @@ vi.mock('~/core/hooks/use-comments', () => ({ // The feed's Claims badge reads the debate's transcript claims through react-query, and this // suite renders the feed without a QueryClientProvider. Stub it the way the other debate suites do; // the grouping and ordering have their own unit tests. -vi.mock('~/core/debates/use-debate-transcript-claims', () => ({ - useDebateTranscriptClaims: () => ({ - claims: { all: [], byAuthorSpaceId: new Map(), unattributed: [], totalCount: 0 }, - isLoading: false, - error: null, - }), -})); +vi.mock('~/core/debates/use-debate-transcript-claims', async () => { + // The real empty value rather than a hand-rolled copy of it. A literal here has to be updated + // every time the shape grows a field, and when it isn't, it fails as a runtime TypeError in a + // suite that has nothing to do with claims. + const { EMPTY_TRANSCRIPT_CLAIMS } = await vi.importActual( + '~/core/debates/transcript-claims' + ); + + return { + useDebateTranscriptClaims: () => ({ claims: EMPTY_TRANSCRIPT_CLAIMS, isLoading: false, error: null }), + }; +}); vi.mock('~/core/hooks/use-entity-side-panel', () => ({ useEntitySidePanel: () => ({ openSidePanel: mocks.openSidePanel, closeSidePanel: vi.fn(), sidePanelTarget: null }), From 172ebffdde6db8f38a24655aedabcda950fe493c Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 14:03:01 -0700 Subject: [PATCH 04/71] refactor(debates): move claim cards into the debater's corner as a fading stack (GEO-2909) A card centred over the video reads as a dialog demanding an answer, and it covers the face of the person making the argument. These now behave like a live chat in the corner the debater's name already occupies. - Small cards anchored above the name, on the tile of whoever said the claim. - The newest arrives at the bottom, nearest the name; earlier ones ride up above it. - Each fades in and out with its own window, driven by the playhead rather than a CSS animation, so scrubbing into the middle of a claim finds the card at full strength instead of mid-animation. - Two at a time. The stack grows upward and the subtitle sits a little higher on the same tile; a third card reaches into it. - No dismiss control and no speaker line: the cards age out on their own, and the name they would repeat is directly below them. Passing on a claim deliberately stays available on the end-of-debate card, where nothing else will take it away. PositionRow is not reused for the pills, for size rather than taste: it is a container query that stacks its two buttons vertically below ~230px, which is the width this card wants to be. The control logic and the vocabulary table are shared, so a factual claim still reads Verify/Dispute and the publish path is unchanged. --- .../browse/debate-claim-ticker.test.tsx | 45 ++-- .../debates/browse/debate-claim-ticker.tsx | 217 +++++++++++++----- .../debates/browse/debate-feed-player.tsx | 40 ++-- .../core/debates/browse/debate-scorecard.tsx | 16 +- apps/web/core/debates/claim-ticker.test.ts | 73 +++--- apps/web/core/debates/claim-ticker.ts | 70 ++++-- apps/web/scripts/verify-claim-timing.ts | 10 +- 7 files changed, 320 insertions(+), 151 deletions(-) diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 0f539e1f84..337482aa9b 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -69,15 +69,7 @@ function window(overrides: Partial = {}): TickerWindow { function renderCard(props: Partial> = {}) { return render( - + ); } @@ -89,13 +81,26 @@ beforeEach(() => { afterEach(cleanup); describe('DebateClaimTickerCard', () => { - it('names the debater and quotes the claim', () => { + it('quotes the claim', () => { renderCard(); - expect(screen.getByText('Preston Mantel just said')).toBeInTheDocument(); expect(screen.getByText(/Supreme Court is no longer providing/)).toBeInTheDocument(); }); + // It sits in the debater's own corner, above their name, so repeating the name on the card is + // noise in the smallest space on the screen. + it('does not repeat the speaker, who is named directly below it', () => { + renderCard(); + + expect(screen.queryByText(/just said/)).not.toBeInTheDocument(); + }); + + it('fades with its window rather than holding at full strength', () => { + const { container } = renderCard({ opacity: 0.4 }); + + expect(container.firstElementChild).toHaveStyle({ opacity: '0.4' }); + }); + // Showing the split first biases the answer, which makes the tally a measure of itself. It is // also the payoff for answering, so it has to be withheld to be worth anything. it('withholds the crowd split until the viewer has taken a side', () => { @@ -129,29 +134,13 @@ describe('DebateClaimTickerCard', () => { expect(onAnswered).not.toHaveBeenCalled(); }); - it('can be waved away without answering', () => { - const onDismiss = vi.fn(); - - renderCard({ onDismiss }); - fireEvent.click(screen.getByLabelText('Dismiss claim')); - - expect(onDismiss).toHaveBeenCalledExactlyOnceWith('claim-1'); - }); - // The video behind the card is one large play/pause button. it('does not toggle playback when the card itself is clicked', () => { const onToggle = vi.fn(); render( ); fireEvent.click(screen.getByText(/Supreme Court is no longer providing/)); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 16a2c79129..84b11a3d35 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -7,24 +7,36 @@ import cx from 'classnames'; import { ClaimSummary } from '~/core/claims/browse/claim-summary'; import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { Debate, DebateClaim } from '~/core/debates/api'; -import { type ClaimMarker, type TickerWindow, activeTickerClaim, claimMarkers, tickerWindows } from '~/core/debates/claim-ticker'; +import { + type ClaimMarker, + type StackedCard, + type TickerWindow, + claimMarkers, + tickerStack, + tickerWindows, +} from '~/core/debates/claim-ticker'; import { type TimedClaim, claimsInSpokenOrder } from '~/core/debates/claim-timing'; import { useDebateClaimsBySpaces } from '~/core/debates/hooks'; -import { PositionRow, useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { uuidToHex } from '~/core/id/normalize'; +import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; -import { Close } from '~/design-system/icons/close'; import { Text } from '~/design-system/text'; export type DebateTicker = { - /** The claim to draw over the video right now, or null. */ - active: TickerWindow | null; + /** + * The cards to draw over each debater right now, oldest first, keyed by participant slot. + * + * Split by slot because a card belongs over the person who said it: the corner it sits in is + * their corner, above their name. + */ + stacks: Map; /** Every precisely-placed claim, for the scrubber. */ markers: ClaimMarker[]; /** Claims in the order they were said, for the card at the end. */ @@ -65,10 +77,6 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled const timedClaims = React.useMemo(() => claimsInSpokenOrder(claims.all, timings), [claims.all, timings]); const windows = React.useMemo(() => tickerWindows(timedClaims), [timedClaims]); - const active = React.useMemo( - () => (enabled ? activeTickerClaim(windows, playheadMs, dismissed) : null), - [enabled, windows, playheadMs, dismissed] - ); // The debate's own length rather than the video element's: the two recordings are composited // against the debate timeline, which is what the scrubber measures. @@ -113,28 +121,51 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled // Attribution rides the *block*, not the claim: a claim's own space is the debate's publication // space, which both debaters share. The block's `Authors` relation points at the speaker's // personal space, which is the id the participant list keys on. - const speakerByClaimId = React.useMemo(() => { - const labelBySpace = new Map(); + const { speakerByClaimId, slotByClaimId } = React.useMemo(() => { + const bySpace = new Map(); for (const participant of orderedParticipants(debate)) { - labelBySpace.set(uuidToHex(participant.profile_space_id), speakerLabel(participant)); + bySpace.set(uuidToHex(participant.profile_space_id), { + label: speakerLabel(participant), + slot: participant.participant_slot, + }); } - const labelByBlock = new Map(); + const byBlock = new Map(); for (const block of claims.blocks) { - const label = block.authorSpaceId ? labelBySpace.get(uuidToHex(block.authorSpaceId)) : undefined; - if (label) labelByBlock.set(block.id, label); + const speaker = block.authorSpaceId ? bySpace.get(uuidToHex(block.authorSpaceId)) : undefined; + if (speaker) byBlock.set(block.id, speaker); } - const byClaim = new Map(); + const labels = new Map(); + const slots = new Map(); for (const claim of claims.all) { - const label = labelByBlock.get(claim.blockId); - if (label) byClaim.set(claim.id, label); + const speaker = byBlock.get(claim.blockId); + if (!speaker) continue; + labels.set(claim.id, speaker.label); + slots.set(claim.id, speaker.slot); } - return byClaim; + return { speakerByClaimId: labels, slotByClaimId: slots }; }, [claims.all, claims.blocks, debate]); + // One stack per debater. A claim whose speaker could not be resolved — attribution and the + // participant list can disagree — is left out rather than parked over whichever tile: putting a + // claim over the wrong face is the misquote this whole layer is careful about. + const stacks = React.useMemo(() => { + const bySlot = new Map(); + if (!enabled) return bySlot; + + for (const card of tickerStack(windows, playheadMs, dismissed)) { + const slot = slotByClaimId.get(card.window.claim.id); + if (slot === undefined) continue; + const existing = bySlot.get(slot); + if (existing) existing.push(card); + else bySlot.set(slot, [card]); + } + return bySlot; + }, [enabled, windows, playheadMs, dismissed, slotByClaimId]); + return { - active, + stacks, markers, claims: timedClaims, answered, @@ -156,18 +187,17 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled */ export function DebateClaimTickerCard({ window, - speaker, + opacity = 1, row, entity, onAnswered, - onDismiss, }: { window: TickerWindow; - speaker: string | null; + /** Driven by the playhead, so a scrub lands on the right strength rather than mid-animation. */ + opacity?: number; row: DebateClaim | null; entity: Entity | null; onAnswered: (claimId: string) => void; - onDismiss: (claimId: string) => void; }) { const { claim } = window; @@ -178,27 +208,10 @@ export function DebateClaimTickerCard({ // The video behind is one big play/pause button; without this every pill press would also // toggle playback. onClick={event => event.stopPropagation()} - className="pointer-events-auto w-full max-w-[26rem] rounded-lg bg-white p-3 shadow-card" + style={{ opacity }} + className="pointer-events-auto w-full rounded-md bg-white/95 px-2.5 py-2 shadow-card backdrop-blur-[2px]" > -
-
- - {speaker ? `${speaker} just said` : 'Just said'} - - - {claim.text} - -
- -
- +

{claim.text}

; + entitiesByClaimId: Map; + onAnswered: (claimId: string) => void; +}) { + if (cards.length === 0) return null; + + return ( +
+ {cards.map(card => ( + + ))} +
+ ); +} + /** - * The same pills, the same publish path and the same vocabulary as every other claim surface. + * The same control logic and the same publish path as every other claim surface, drawn small. + * + * `PositionRow` is not reused here, and the reason is size rather than taste: it is a container + * query that stacks its two pills vertically below ~230px, which is exactly the width this card + * wants to be. Reusing it would force the card wide enough to cover the face it sits beside. What + * matters is shared underneath — `useClaimPositionControl` publishes the response, and the labels + * come from the same vocabulary table, so a factual claim still reads Verify/Dispute here. * * The crowd split is deliberately withheld until the viewer has answered. Showing it first biases * the answer and makes the tally a measure of itself; withholding it also gives the tap a payoff. @@ -266,16 +321,28 @@ function TickerClaimControls({ } }, [answered, claimId, onAnswered]); + const copy = ENTITY_RESPONSE_COPY[responseKind]; + return ( -
- +
+
+ control.respond(true)} + /> + control.respond(false)} + /> +
{control.responseError ? (
@@ -296,6 +363,48 @@ function TickerClaimControls({ ); } +/** + * One side of the answer, sized for a card that sits beside a face rather than over it. + * + * Deliberately plain: no responder avatars, no percentage. Those belong on a row the reader is + * studying, and here they would be three more things moving in the corner of a playing video. + */ +function CompactPill({ + label, + selected, + disabled, + title, + tone, + onClick, +}: { + label: string; + selected: boolean; + disabled: boolean; + title: string; + tone: 'positive' | 'negative'; + onClick: () => void; +}) { + return ( + + ); +} + /** Claim markers on the scrubber, each a place the viewer can jump to. */ export function ClaimScrubberMarkers({ markers, diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index d7ba94b1ba..72a1906650 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -18,11 +18,12 @@ import { Avatar } from '~/design-system/avatar'; import { RetrySmall } from '~/design-system/icons/retry-small'; import { Text } from '~/design-system/text'; -import { ClaimScrubberMarkers, DebateClaimTickerCard, useDebateClaimTicker } from './debate-claim-ticker'; +import { ClaimScrubberMarkers, DebateClaimTickerStack, useDebateClaimTicker } from './debate-claim-ticker'; import { DebateScorecard } from './debate-scorecard'; import { Play, Speaker, SpeakerMuted } from './icons'; import { WinnerVoteButton } from './winner-vote-button'; -import type { ClaimMarker } from '~/core/debates/claim-ticker'; +import type { ClaimMarker, StackedCard } from '~/core/debates/claim-ticker'; +import type { DebateTicker } from './debate-claim-ticker'; type DebateFeedPlayerProps = { debate: Debate; @@ -116,6 +117,8 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb audible={playing && turnState?.slot === 1} countdown={playing && turnState?.slot === 1 ? turnState : null} subtitle={activeSlot === 1 ? subtitle : null} + claims={playbackEnded ? [] : (ticker.stacks.get(1) ?? [])} + ticker={ticker} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} @@ -154,6 +157,8 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb audible={playing && turnState?.slot === 2} countdown={playing && turnState?.slot === 2 ? turnState : null} subtitle={activeSlot === 2 ? subtitle : null} + claims={playbackEnded ? [] : (ticker.stacks.get(2) ?? [])} + ticker={ticker} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} @@ -184,23 +189,6 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } /> - {/* The seam between the two tiles is the one strip of the player that is never a face, so - the claim card and the end-of-debate card both sit there. `pointer-events-none` on the - positioner keeps the rest of the seam clickable for play/pause. */} - {ready && !playbackEnded && ticker.active && ( -
- -
- )} - {ready && playbackEnded && (
@@ -234,6 +222,8 @@ function DebaterVideo({ audible, countdown, subtitle, + claims, + ticker, mutedByUser, isResuming, onPlaybackTick, @@ -248,6 +238,9 @@ function DebaterVideo({ audible: boolean; countdown: TurnState; subtitle: string | null; + /** The claims this debater is making right now, oldest first. */ + claims: StackedCard[]; + ticker: DebateTicker; mutedByUser: boolean; isResuming: boolean; onPlaybackTick: () => void; @@ -349,6 +342,15 @@ function DebaterVideo({
)} + {/* Above the name, in this debater's own corner. The subtitle sits higher up, so the two + do not collide. */} + + {/* Debater identity: avatar + name + position, opens their personal space in the side panel. */}
+ {/* The live card has no dismiss — it fades on its own. Here nothing is going to take the + claim away, so passing on it has to be a thing the reader can say. */} + ) : ( diff --git a/apps/web/core/debates/claim-ticker.test.ts b/apps/web/core/debates/claim-ticker.test.ts index 5bf85bd939..1bfaa4b05e 100644 --- a/apps/web/core/debates/claim-ticker.test.ts +++ b/apps/web/core/debates/claim-ticker.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ClaimTiming, TimedClaim } from './claim-timing'; -import { CLAIM_LINGER_MS, activeTickerClaim, claimMarkers, scoreDebate, tickerWindows } from './claim-ticker'; +import { CLAIM_LINGER_MS, cardOpacity, claimMarkers, scoreDebate, tickerStack, tickerWindows } from './claim-ticker'; function timed(id: string, timing: ClaimTiming | null, text = `Claim ${id}`): TimedClaim { return { id, text, spaceId: 'space-1', blockId: 'block-1', publishedTiming: null, timing }; @@ -57,55 +57,74 @@ describe('tickerWindows', () => { }); }); -describe('activeTickerClaim', () => { +describe('tickerStack', () => { const windows = tickerWindows([ timed('first', confident(10_000, 14_000)), timed('second', confident(18_000, 22_000)), ]); + const ids = (playheadMs: number, dismissed?: Set) => + tickerStack(windows, playheadMs, dismissed).map(card => card.window.claim.id); + it('shows nothing before the first claim is said', () => { - expect(activeTickerClaim(windows, 5_000)).toBeNull(); + expect(ids(5_000)).toEqual([]); }); it('shows the claim being said', () => { - expect(activeTickerClaim(windows, 12_000)?.claim.id).toBe('first'); + expect(ids(12_000)).toEqual(['first']); }); - it('keeps it up through the linger, then hands over to the next claim', () => { - expect(activeTickerClaim(windows, 18_000 - 1)?.claim.id).toBe('first'); - // The first claim's linger runs to 19s, but the second starts at 18s and takes over. - expect(activeTickerClaim(windows, 14_000 + CLAIM_LINGER_MS)?.claim.id).toBe('second'); + // The newest is last, so rendered down a column it sits at the bottom, nearest the name, with + // the older one riding up above it. + it('stacks an overlapping claim under the one before it, newest last', () => { + expect(ids(18_500)).toEqual(['first', 'second']); }); - it('lets a claim go once its linger expires with nothing after it', () => { - const lone = tickerWindows([timed('only', confident(10_000, 14_000))]); - - expect(activeTickerClaim(lone, 14_000 + CLAIM_LINGER_MS - 1)?.claim.id).toBe('only'); - expect(activeTickerClaim(lone, 14_000 + CLAIM_LINGER_MS)).toBeNull(); + it('drops a claim once its window closes', () => { + expect(ids(14_000 + CLAIM_LINGER_MS)).toEqual(['second']); }); - it('follows the debate when two claims overlap, rather than lagging on the older one', () => { - const overlapping = tickerWindows([ - timed('earlier', confident(10_000, 20_000)), - timed('later', confident(14_000, 18_000)), + // Five claims inside ten seconds is an ordinary turn; the corner has to stay a corner. + it('keeps only the most recent few when a turn is busy', () => { + const busy = tickerWindows([ + timed('a', confident(1_000, 2_000)), + timed('b', confident(1_500, 2_500)), + timed('c', confident(2_000, 3_000)), + timed('d', confident(2_500, 3_500)), ]); - expect(activeTickerClaim(overlapping, 12_000)?.claim.id).toBe('earlier'); - expect(activeTickerClaim(overlapping, 15_000)?.claim.id).toBe('later'); + expect(tickerStack(busy, 3_000, new Set(), 3).map(card => card.window.claim.id)).toEqual(['b', 'c', 'd']); }); - // Seeking backwards over a claim you already answered should not ask again. it('does not re-ask a claim the viewer has answered or dismissed', () => { - expect(activeTickerClaim(windows, 12_000, new Set(['first']))).toBeNull(); + expect(ids(12_000, new Set(['first']))).toEqual([]); }); +}); - it('falls through to another live claim when the top one is dismissed', () => { - const overlapping = tickerWindows([ - timed('earlier', confident(10_000, 20_000)), - timed('later', confident(14_000, 18_000)), - ]); +describe('cardOpacity', () => { + const [window] = tickerWindows([timed('a', confident(10_000, 14_000))]); + + it('fades in rather than blinking into place', () => { + expect(cardOpacity(window, 10_000)).toBe(0); + expect(cardOpacity(window, 10_125)).toBeCloseTo(0.5); + expect(cardOpacity(window, 10_400)).toBe(1); + }); + + it('holds at full strength through the middle', () => { + expect(cardOpacity(window, 12_000)).toBe(1); + }); + + it('fades out over the tail of its window', () => { + expect(cardOpacity(window, window.endMs - 750)).toBeCloseTo(0.5); + expect(cardOpacity(window, window.endMs - 1)).toBeLessThan(0.01); + }); - expect(activeTickerClaim(overlapping, 15_000, new Set(['later']))?.claim.id).toBe('earlier'); + // A scrub lands wherever it lands; the card has to be as visible as its moment says, not as + // visible as an animation that started when it mounted. + it('is driven by the playhead, so a scrub into the middle lands at full strength', () => { + expect(cardOpacity(window, 13_000)).toBe(1); + expect(cardOpacity(window, 9_000)).toBe(0); + expect(cardOpacity(window, 99_000)).toBe(0); }); }); diff --git a/apps/web/core/debates/claim-ticker.ts b/apps/web/core/debates/claim-ticker.ts index 92db09a772..647c9fad42 100644 --- a/apps/web/core/debates/claim-ticker.ts +++ b/apps/web/core/debates/claim-ticker.ts @@ -33,30 +33,66 @@ export function tickerWindows(claims: TimedClaim[]): TickerWindow[] { } /** - * The claim to show at this moment, or null. + * How many cards may be stacked over a debater at once, before the oldest is dropped. * - * One card at a time, by design: two debaters talking over each other is the debate, but two cards - * is a form. When windows overlap — the test debate has a pair eight seconds apart — the most - * recently started wins, so the card always tracks what is being said now rather than lagging on - * something that has moved on. + * Two, not three: the stack grows upward from just above the debater's name, and the subtitle sits + * a little higher on the same tile. Three cards reach into it, which is the crowding this layout + * exists to avoid. + */ +export const MAX_STACKED_CARDS = 2; + +/** Long enough to register as arriving rather than blinking into place. */ +const FADE_IN_MS = 250; +/** The tail of a card's window, over which it fades out instead of vanishing. */ +const FADE_OUT_MS = 1_500; + +/** + * How visible a card is at this moment, in [0, 1]. + * + * Driven by the playhead rather than a CSS animation, because the playhead is the source of truth + * and it can jump: a viewer who scrubs back into the middle of a claim should find the card at full + * strength, not mid-way through an animation that started when the element mounted. + */ +export function cardOpacity(window: TickerWindow, playheadMs: number): number { + if (playheadMs < window.startMs || playheadMs >= window.endMs) return 0; + + const sinceStart = playheadMs - window.startMs; + if (sinceStart < FADE_IN_MS) return sinceStart / FADE_IN_MS; + + const untilEnd = window.endMs - playheadMs; + if (untilEnd < FADE_OUT_MS) return untilEnd / FADE_OUT_MS; + + return 1; +} + +export type StackedCard = { window: TickerWindow; opacity: number }; + +/** + * The claims to show over a debater right now, oldest first. + * + * Rendered in this order down a column, the newest card sits at the bottom, nearest the debater's + * name, and earlier ones ride up above it as they age out — the shape of a live chat rather than a + * dialog. Capped at {@link MAX_STACKED_CARDS}: a busy turn can put five claims inside ten seconds, + * and the point of the corner is that it leaves the face alone. * * `dismissed` carries the claims the viewer has answered or waved away. They are skipped rather * than redrawn, so seeking backwards does not re-ask a question already answered. */ -export function activeTickerClaim( +export function tickerStack( windows: TickerWindow[], playheadMs: number, - dismissed: ReadonlySet = new Set() -): TickerWindow | null { - let active: TickerWindow | null = null; - - for (const window of windows) { - if (dismissed.has(window.claim.id)) continue; - if (playheadMs < window.startMs || playheadMs >= window.endMs) continue; - if (active === null || window.startMs > active.startMs) active = window; - } - - return active; + dismissed: ReadonlySet = new Set(), + max: number = MAX_STACKED_CARDS +): StackedCard[] { + return windows + .filter( + window => + !dismissed.has(window.claim.id) && playheadMs >= window.startMs && playheadMs < window.endMs + ) + .sort((a, b) => a.startMs - b.startMs) + // Drop the oldest when there are more than fit, so what is on screen is what was just said. + .slice(-max) + .map(window => ({ window, opacity: cardOpacity(window, playheadMs) })); } export type ClaimMarker = { id: string; text: string; atMs: number; fraction: number }; diff --git a/apps/web/scripts/verify-claim-timing.ts b/apps/web/scripts/verify-claim-timing.ts index 3a0ac2dc7b..0a1d27151f 100644 --- a/apps/web/scripts/verify-claim-timing.ts +++ b/apps/web/scripts/verify-claim-timing.ts @@ -6,7 +6,7 @@ * * Usage: bun scripts/verify-claim-timing.ts */ -import { activeTickerClaim, claimMarkers, tickerWindows } from '../core/debates/claim-ticker'; +import { claimMarkers, tickerStack, tickerWindows } from '../core/debates/claim-ticker'; import { claimsInSpokenOrder, formatTimecode, resolveClaimTimings } from '../core/debates/claim-timing'; import { AUTHORS_PROPERTY_ID, @@ -118,8 +118,12 @@ console.log(`scrubber markers: ${markers.length}`); console.log('\nWhat the viewer sees, sampled every 10s:'); for (let ms = 0; ms <= 270_000; ms += 10_000) { - const active = activeTickerClaim(windows, ms); - if (active) console.log(` ${formatTimecode(ms).padStart(5)} ${active.claim.text.slice(0, 62)}`); + const stack = tickerStack(windows, ms); + for (const card of stack) { + console.log( + ` ${formatTimecode(ms).padStart(5)} [${card.opacity.toFixed(2)}] ${card.window.claim.text.slice(0, 56)}` + ); + } } const missing = ordered.filter(claim => claim.timing === null); From fcf6a6259654fd2c4cf9350116d71b738012ab3f Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 14:30:57 -0700 Subject: [PATCH 05/71] refactor(debates): make the live claims ambient, and move the ask to the end (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A white card with bordered buttons over the video reads as app chrome stapled onto the frame, and asking for a position every few seconds while someone is talking is a lot to hold. The two halves now do different jobs. During playback, claims are ambient. Each is a line of white text on a soft scrim, in the same visual language as the subtitle, rising and fading in the speaker's own corner. Interaction is two small icons at the end of the line — recessive until hovered, unmistakable once a side is taken — so it stays available without asking. Thumbs for a stance claim, chevrons for a factual one, matching the split the rest of the app already draws. No crowd split on the line: it would resize the card mid-playback, and the line has to stay a line. The end card is now the ask rather than a consolation. Dimmed backdrop so it reads as the moment the debate arrives at, a header that says what is being asked, one claim at a time with labelled buttons, and it advances itself as each is answered. Fixes two things visible on the old version: the card overlapped the subtitle, because both wanted the strip above the debater's name — they now share one bottom-anchored column and cannot collide — and "Skip this one" ran straight into "Watch again" with no space between them. --- .../browse/debate-claim-ticker.test.tsx | 32 ++- .../debates/browse/debate-claim-ticker.tsx | 116 ++++----- .../debates/browse/debate-feed-player.tsx | 34 +-- .../core/debates/browse/debate-scorecard.tsx | 235 +++++++++++++----- 4 files changed, 279 insertions(+), 138 deletions(-) diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 337482aa9b..96aa8b3d53 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -11,8 +11,8 @@ import { DebateClaimTickerCard } from './debate-claim-ticker'; const CLAIM_SPACE = '52c7ae149838b6d47ce0f3b2a5974546'; const mocks = vi.hoisted(() => ({ - /** What the shared position control reports the viewer currently holds. */ - viewerPosition: null as string | null, + /** What the shared position control reports the viewer currently holds — a side, not a string. */ + viewerPosition: null as boolean | null, respond: vi.fn(), })); @@ -101,24 +101,38 @@ describe('DebateClaimTickerCard', () => { expect(container.firstElementChild).toHaveStyle({ opacity: '0.4' }); }); - // Showing the split first biases the answer, which makes the tally a measure of itself. It is - // also the payoff for answering, so it has to be withheld to be worth anything. - it('withholds the crowd split until the viewer has taken a side', () => { + // The line has to stay a line. A split appearing under it mid-playback would resize the card + // while the reader is looking at it, and the numbers have a home on the end-of-debate card. + it('never grows a crowd split, answered or not', () => { renderCard(); + expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); + cleanup(); + mocks.viewerPosition = true; + renderCard(); expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); }); - it('reveals the crowd split once the viewer has answered', () => { - mocks.viewerPosition = 'positive'; + // Small, recessive, and unlabelled on screen — so the accessible name is the only thing telling + // a screen reader which side is which. + it('offers both sides as icons that still name themselves', () => { + renderCard(); + + expect(screen.getByLabelText('Agree')).toBeInTheDocument(); + expect(screen.getByLabelText('Disagree')).toBeInTheDocument(); + }); + + it('shows which side the viewer took', () => { + mocks.viewerPosition = true; renderCard(); - expect(screen.getByTestId('crowd-split')).toBeInTheDocument(); + expect(screen.getByLabelText('Agree')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByLabelText('Disagree')).toHaveAttribute('aria-pressed', 'false'); }); it('reports the answer up so the card is not asked again on a rewind', () => { - mocks.viewerPosition = 'positive'; + mocks.viewerPosition = true; const onAnswered = vi.fn(); renderCard({ onAnswered }); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 84b11a3d35..a1fdf2e2e8 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -4,7 +4,6 @@ import * as React from 'react'; import cx from 'classnames'; -import { ClaimSummary } from '~/core/claims/browse/claim-summary'; import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { Debate, DebateClaim } from '~/core/debates/api'; import { @@ -27,7 +26,10 @@ import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; -import { Text } from '~/design-system/text'; +import { ChevronDown } from '~/design-system/icons/chevron-down'; +import { ChevronUp } from '~/design-system/icons/chevron-up'; +import { ThumbDown } from '~/design-system/icons/thumb-down'; +import { ThumbUp } from '~/design-system/icons/thumb-up'; export type DebateTicker = { /** @@ -205,13 +207,13 @@ export function DebateClaimTickerCard({ return (
event.stopPropagation()} style={{ opacity }} - className="pointer-events-auto w-full rounded-md bg-white/95 px-2.5 py-2 shadow-card backdrop-blur-[2px]" + className="pointer-events-auto flex max-w-[min(24rem,78%)] items-start gap-2 rounded bg-black/65 px-2 py-1 backdrop-blur-[2px]" > -

{claim.text}

+ {claim.text} +
{cards.map(card => ( -
- control.respond(true)} - /> - control.respond(false)} - /> -
- {control.responseError ? ( -
- - {control.responseError} - -
- ) : null} - {answered && !summary.isLoading ? ( - - ) : null} -
+ + control.respond(true)} + /> + control.respond(false)} + /> + {/* No crowd split and no error text on the line. Both would make it grow mid-playback, and + the line has to stay one line. The end-of-debate card is where the numbers live. */} + ); } /** - * One side of the answer, sized for a card that sits beside a face rather than over it. + * One side of the answer, as an icon rather than a labelled pill. + * + * A labelled button turns the line into a form. An icon keeps the line a line — the reader takes + * in what was said, and the affordance is there in the corner of their eye if they feel strongly + * about it. The label survives as the accessible name and the tooltip, so nothing is lost to + * anyone reading it aloud or hovering. * - * Deliberately plain: no responder avatars, no percentage. Those belong on a row the reader is - * studying, and here they would be three more things moving in the corner of a playing video. + * Thumbs for a stance claim, chevrons for a factual one, which is the split the rest of the app + * already draws: agreeing with a position and verifying a fact are different acts, and a thumb on + * "the SEC sued Coinbase" reads as approval rather than confirmation. */ -function CompactPill({ +function ClaimIconButton({ + responseKind, + position, label, selected, disabled, title, - tone, onClick, }: { + responseKind: 'stance' | 'veracity' | 'curation'; + position: boolean; label: string; selected: boolean; disabled: boolean; title: string; - tone: 'positive' | 'negative'; onClick: () => void; }) { + const Icon = responseKind === 'veracity' ? (position ? ChevronUp : ChevronDown) : position ? ThumbUp : ThumbDown; + return ( ); } diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 72a1906650..6c4fd16c35 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -189,8 +189,10 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } /> + {/* Dimmed behind, so the card reads as the moment the debate arrives at rather than a note + stuck over two frozen faces. */} {ready && playbackEnded && ( -
+
)} @@ -334,23 +336,25 @@ function DebaterVideo({ {countdown && } - {subtitle && ( -
- - {subtitle} - + {/* Claims and the subtitle share one bottom-anchored column so they cannot land on top of + each other — they did, because both wanted the strip above the debater's name. The + subtitle stays pinned at the bottom of the column and the claims rise above it. */} + {(subtitle || claims.length > 0) && ( +
+ + {subtitle && ( + + {subtitle} + + )}
)} - {/* Above the name, in this debater's own corner. The subtitle sits higher up, so the two - do not collide. */} - - {/* Debater identity: avatar + name + position, opens their personal space in the side panel. */} + {next ? ( - - ) : ( - - That is every claim from this debate. Now say who won. - - )} + ) : null} + +
+ ); +} + +/** + * One claim, with both sides spelled out. + * + * Labelled buttons here, unlike the floating lines: nothing is moving, the reader is being asked a + * question directly, and Verify/Dispute versus Agree/Disagree is what tells them which kind of + * question it is. + */ +function ScorecardClaim({ + claim, + speaker, + row, + entity, + onAnswered, +}: { + claim: TimedClaim; + speaker: string | null; + row: DebateClaim | null; + entity: Entity | null; + onAnswered: (claimId: string) => void; +}) { + const promptSignIn = usePrivySignIn(); + // Narrowed by the caller, which only passes claims that have a space to publish into. + const spaceId = claim.spaceId as string; + const { + responseKind, + isResponseKindResolved, + isViewerResponseResolved, + responseBlockedReason, + claim: claimSummary, + positions, + readiness, + } = useClaimResponseState({ claimId: claim.id, spaceId, row, entity }); + + const control = useClaimPositionControl({ + claim: claimSummary, + positions, + readiness, + answersReady: isResponseKindResolved && isViewerResponseResolved, + responseBlockedReason, + onRequireSignIn: promptSignIn, + offersDebate: false, + }); - + // Advance as soon as the viewer answers, so the card walks itself rather than making them press + // a "next" after every claim. The response is already published by the time this fires. + const reported = React.useRef(false); + React.useEffect(() => { + if (control.viewerPosition !== null && !reported.current) { + reported.current = true; + onAnswered(claim.id); + } + }, [control.viewerPosition, claim.id, onAnswered]); + + const copy = ENTITY_RESPONSE_COPY[responseKind]; + + return ( +
+

+ {speaker ? `${speaker} said` : 'Said'} + {claim.timing ? ` at ${formatTimecode(claim.timing.startMs)}` : null} +

+

{claim.text}

+ +
+ control.respond(true)} + /> + control.respond(false)} + /> +
+ + {control.responseError ? ( +

+ {control.responseError} +

+ ) : null}
); } + +function SideButton({ + label, + tone, + disabled, + title, + onClick, +}: { + label: string; + tone: 'positive' | 'negative'; + disabled: boolean; + title: string; + onClick: () => void; +}) { + return ( + + ); +} From f361c7ac7245d709b839e577928e0900a0805aed Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 14:47:56 -0700 Subject: [PATCH 06/71] refactor(debates): tighten the claim line and the end card (GEO-2909) Three things from a design pass over the first build. A claim line is now capped at the width of the debater's name row below it, so it stops where the position chip stops instead of running out across the frame. That width is measured: the row is as wide as the name, names vary, and there is no CSS way to say "no wider than that sibling" when the sibling is absolutely positioned and sized by its own content. Without a ResizeObserver the line falls back to its own max width. The end card now answers with the same icon buttons as the floating lines, in a light-surface variant, so answering in either place reads as one act rather than two features. The labelled Agree/Disagree boxes are gone. Its chrome is rearranged to match: Watch again sits in the header's top right, and Skip moved onto the same row as the speaker and timecode, right aligned. That removes the footer, and with it the two links that had been sitting flush against each other. --- .../debates/browse/debate-claim-ticker.tsx | 48 +++++-- .../debates/browse/debate-feed-player.tsx | 32 +++++ .../core/debates/browse/debate-scorecard.tsx | 136 ++++++++---------- 3 files changed, 124 insertions(+), 92 deletions(-) diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index a1fdf2e2e8..fd01333b6e 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -211,7 +211,7 @@ export function DebateClaimTickerCard({ // also toggle playback. onClick={event => event.stopPropagation()} style={{ opacity }} - className="pointer-events-auto flex max-w-[min(24rem,78%)] items-start gap-2 rounded bg-black/65 px-2 py-1 backdrop-blur-[2px]" + className="pointer-events-auto flex w-full items-start gap-2 rounded bg-black/65 px-2 py-1 backdrop-blur-[2px]" > {claim.text} ; entitiesByClaimId: Map; onAnswered: (claimId: string) => void; @@ -246,7 +249,10 @@ export function DebateClaimTickerStack({ if (cards.length === 0) return null; return ( -
+
{cards.map(card => ( void; + /** `video` sits on the dark scrim over a frame; `card` on white. */ + surface?: 'video' | 'card'; + size?: 'sm' | 'md'; + onClick?: () => void; }) { const Icon = responseKind === 'veracity' ? (position ? ChevronUp : ChevronDown) : position ? ThumbUp : ThumbDown; @@ -388,16 +399,29 @@ function ClaimIconButton({ aria-pressed={selected} title={title} disabled={disabled} - onClick={onClick} + onClick={ + onClick && + (event => { + event.stopPropagation(); + onClick(); + }) + } className={cx( - 'grid size-5 place-items-center rounded-sm transition-colors disabled:cursor-default', - // Recessive until it matters: dim on the line, bright on hover, and unmistakable once the + 'grid place-items-center rounded-sm transition-colors disabled:cursor-default', + size === 'md' ? 'size-8' : 'size-5', + // Recessive until it matters: dim at rest, brighter on hover, and unmistakable once the // reader has actually taken a side. - selected - ? position - ? 'bg-white/15 text-green' - : 'bg-white/15 text-red-01' - : 'text-white/55 hover:bg-white/15 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/55' + surface === 'video' + ? selected + ? position + ? 'bg-white/15 text-green' + : 'bg-white/15 text-red-01' + : 'text-white/55 hover:bg-white/15 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/55' + : selected + ? position + ? 'bg-successTertiary text-green' + : 'bg-errorTertiary text-red-01' + : 'text-grey-04 hover:bg-grey-01 hover:text-text disabled:hover:bg-transparent disabled:hover:text-grey-04' )} > diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 6c4fd16c35..472ac811a0 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -253,6 +253,8 @@ function DebaterVideo({ }) { const { openSidePanel } = useEntitySidePanel(); const name = participant ? speakerLabel(participant) : 'Debater'; + const identityRef = React.useRef(null); + const claimWidth = useIdentityRowWidth(identityRef); const muted = !audible || mutedByUser; @@ -341,8 +343,12 @@ function DebaterVideo({ subtitle stays pinned at the bottom of the column and the claims rise above it. */} {(subtitle || claims.length > 0) && (
+ {/* Capped at the width of the name row below, so a claim never runs out past the + debater's position chip. Measured rather than guessed: the row is as wide as the + name, and names vary. */} ): number | null { + const [width, setWidth] = React.useState(null); + + React.useEffect(() => { + const element = ref.current; + if (!element || typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(entries => { + for (const entry of entries) setWidth(entry.contentRect.width); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + + return width; +} + function CountdownBadge({ seconds, progress }: { seconds: number; progress: number }) { // The ring shows time remaining, so it shrinks as the turn elapses rather than filling up. const remaining = 1 - Math.max(0, Math.min(1, progress)); diff --git a/apps/web/core/debates/browse/debate-scorecard.tsx b/apps/web/core/debates/browse/debate-scorecard.tsx index eaadb203d9..e5f15ff132 100644 --- a/apps/web/core/debates/browse/debate-scorecard.tsx +++ b/apps/web/core/debates/browse/debate-scorecard.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; -import cx from 'classnames'; - import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { DebateClaim } from '~/core/debates/api'; import { type TimedClaim, formatTimecode } from '~/core/debates/claim-timing'; @@ -12,7 +10,7 @@ import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; import type { Entity } from '~/core/types'; -import type { DebateTicker } from './debate-claim-ticker'; +import { ClaimIconButton, type DebateTicker } from './debate-claim-ticker'; /** * The ask, once the video has stopped. @@ -48,13 +46,25 @@ export function DebateScorecard({ ticker, onReplay }: { ticker: DebateTicker; on return (
-
-

{next ? 'Where do you stand?' : 'That is all of them'}

-

- {next - ? `${answerable.length} claims were made · ${answered.size} answered` - : `You answered ${answered.size} of ${answerable.length}. Now say who won.`} -

+
+
+

{next ? 'Where do you stand?' : 'That is all of them'}

+

+ {next + ? `${answerable.length} claims were made · ${answered.size} answered` + : `You answered ${answered.size} of ${answerable.length}. Now say who won.`} +

+
+
{next ? ( @@ -65,33 +75,9 @@ export function DebateScorecard({ ticker, onReplay }: { ticker: DebateTicker; on row={rowsByClaimId.get(next.id) ?? null} entity={entitiesByClaimId.get(next.id) ?? null} onAnswered={onAnswered} + onSkip={skip} /> ) : null} - -
- - {next ? ( - - ) : null} -
); } @@ -109,12 +95,14 @@ function ScorecardClaim({ row, entity, onAnswered, + onSkip, }: { claim: TimedClaim; speaker: string | null; row: DebateClaim | null; entity: Entity | null; onAnswered: (claimId: string) => void; + onSkip: (claimId: string) => void; }) { const promptSignIn = usePrivySignIn(); // Narrowed by the caller, which only passes claims that have a space to publish into. @@ -153,25 +141,47 @@ function ScorecardClaim({ return (
-

- {speaker ? `${speaker} said` : 'Said'} - {claim.timing ? ` at ${formatTimecode(claim.timing.startMs)}` : null} -

+
+

+ {speaker ? `${speaker} said` : 'Said'} + {claim.timing ? ` at ${formatTimecode(claim.timing.startMs)}` : null} +

+ +

{claim.text}

-
- + control.respond(true)} /> - control.respond(false)} />
@@ -184,37 +194,3 @@ function ScorecardClaim({
); } - -function SideButton({ - label, - tone, - disabled, - title, - onClick, -}: { - label: string; - tone: 'positive' | 'negative'; - disabled: boolean; - title: string; - onClick: () => void; -}) { - return ( - - ); -} From 74c690940a2c50569466f6cb329da4e8f01d8c22 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 15:08:55 -0700 Subject: [PATCH 07/71] refactor(debates): icons under the claim text, and the real claim pills on the end card (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two design corrections. On a floating line the icons sat to the right of the text, which took roughly half the width of an already-narrow card and pushed a one-sentence claim into five wrapped lines. They now sit under the text, which gets the full width back and makes the card shorter despite the extra row. The end card now renders PositionRow — the claim card's own pills, dashed outline, thumb and label — rather than the bare icons it had been borrowing from the floating lines. That is the control the explore feed, the hub and the claims panel all use to ask this exact question, and unlike a line over a video there is room for it here. The lines keep the icons, which is the one place the shared pill does not fit: it stacks its two buttons vertically below ~230px. --- .../debates/browse/debate-claim-ticker.tsx | 8 ++-- .../core/debates/browse/debate-scorecard.tsx | 38 ++++++------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index fd01333b6e..82b84fde9a 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -211,7 +211,7 @@ export function DebateClaimTickerCard({ // also toggle playback. onClick={event => event.stopPropagation()} style={{ opacity }} - className="pointer-events-auto flex w-full items-start gap-2 rounded bg-black/65 px-2 py-1 backdrop-blur-[2px]" + className="pointer-events-auto flex w-full flex-col gap-0.5 rounded bg-black/65 px-2 py-1.5 backdrop-blur-[2px]" > {claim.text} + control.respond(false)} /> - {/* No crowd split and no error text on the line. Both would make it grow mid-playback, and - the line has to stay one line. The end-of-debate card is where the numbers live. */} + {/* No crowd split and no error text here. Both would make the card grow while the reader is + part-way through it; the end-of-debate card is where the numbers live. */} ); } diff --git a/apps/web/core/debates/browse/debate-scorecard.tsx b/apps/web/core/debates/browse/debate-scorecard.tsx index e5f15ff132..e52e261018 100644 --- a/apps/web/core/debates/browse/debate-scorecard.tsx +++ b/apps/web/core/debates/browse/debate-scorecard.tsx @@ -5,12 +5,11 @@ import * as React from 'react'; import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { DebateClaim } from '~/core/debates/api'; import { type TimedClaim, formatTimecode } from '~/core/debates/claim-timing'; -import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { PositionRow, useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; -import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; import type { Entity } from '~/core/types'; -import { ClaimIconButton, type DebateTicker } from './debate-claim-ticker'; +import type { DebateTicker } from './debate-claim-ticker'; /** * The ask, once the video has stopped. @@ -137,8 +136,6 @@ function ScorecardClaim({ } }, [control.viewerPosition, claim.id, onAnswered]); - const copy = ENTITY_RESPONSE_COPY[responseKind]; - return (
@@ -159,30 +156,17 @@ function ScorecardClaim({

{claim.text}

- {/* The same control as the floating lines, so answering here and answering there look like - the same act rather than two different features. */} -
- control.respond(true)} - /> - + control.respond(false)} + titleFor={control.actionTitle} />
From 17a67871866cf92adb71da4d9824fb5994797059 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 15:48:08 -0700 Subject: [PATCH 08/71] feat(debates): hold the taken side, then tally the lean and ask who won (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the end card. Answering advanced the card the instant the response landed, which swapped the claim out from under the press — the pill never showed as taken and there was no way to tell the tap had registered. It now holds the selected state for a beat first. The response was already published; only the card waits. The viewer's side is recorded, not just the fact that they answered, so the card can say something at the end: which debater they agreed with more often, counted against the claims they actually answered rather than everything that debater said. And the sequence now ends on the debate's own question. Having gone through every claim the viewer has effectively assembled their answer to "who won", so the winner vote is offered right there instead of only on the tiles behind the dimmed backdrop. Same control and the same hook as the tiles, so the two cannot disagree. --- .../browse/debate-claim-ticker.test.tsx | 3 +- .../debates/browse/debate-claim-ticker.tsx | 30 +-- .../debates/browse/debate-feed-player.tsx | 2 +- .../core/debates/browse/debate-scorecard.tsx | 194 +++++++++++++++--- 4 files changed, 181 insertions(+), 48 deletions(-) diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 96aa8b3d53..782267561a 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -137,7 +137,8 @@ describe('DebateClaimTickerCard', () => { renderCard({ onAnswered }); - expect(onAnswered).toHaveBeenCalledExactlyOnceWith('claim-1'); + // Reports which way, not just that — the end card tallies the sides. + expect(onAnswered).toHaveBeenCalledExactlyOnceWith('claim-1', true); }); it('does not report an answer for a claim the viewer has not taken a side on', () => { diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 82b84fde9a..09674d4f74 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -45,7 +45,9 @@ export type DebateTicker = { claims: TimedClaim[]; /** Claim ids the viewer has answered this session. */ answered: ReadonlySet; - onAnswered: (claimId: string) => void; + /** Which way they answered each one, for the tally at the end. */ + answers: ReadonlyMap; + onAnswered: (claimId: string, position: boolean) => void; onDismiss: (claimId: string) => void; /** Per-claim lookups, hoisted so the card and the end-of-debate stack share one batch. */ rowsByClaimId: Map; @@ -65,14 +67,17 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled const { claims } = useDebateTranscriptClaims(debate.id, debate.claim.space_id, enabled); const { timings } = useClaimTimings(debate.id, claims, enabled); - const [answered, setAnswered] = React.useState>(() => new Set()); + const [answers, setAnswers] = React.useState>(() => new Map()); const [dismissed, setDismissed] = React.useState>(() => new Set()); - const onAnswered = React.useCallback((claimId: string) => { - setAnswered(current => new Set(current).add(claimId)); + const onAnswered = React.useCallback((claimId: string, position: boolean) => { + setAnswers(current => new Map(current).set(claimId, position)); setDismissed(current => new Set(current).add(claimId)); }, []); + // The ids alone, for every caller that only asks "has this been answered". + const answered = React.useMemo(() => new Set(answers.keys()), [answers]); + const onDismiss = React.useCallback((claimId: string) => { setDismissed(current => new Set(current).add(claimId)); }, []); @@ -171,6 +176,7 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled markers, claims: timedClaims, answered, + answers, onAnswered, onDismiss, rowsByClaimId, @@ -199,7 +205,7 @@ export function DebateClaimTickerCard({ opacity?: number; row: DebateClaim | null; entity: Entity | null; - onAnswered: (claimId: string) => void; + onAnswered: (claimId: string, position: boolean) => void; }) { const { claim } = window; @@ -244,7 +250,7 @@ export function DebateClaimTickerStack({ maxWidth?: number | null; rowsByClaimId: Map; entitiesByClaimId: Map; - onAnswered: (claimId: string) => void; + onAnswered: (claimId: string, position: boolean) => void; }) { if (cards.length === 0) return null; @@ -291,7 +297,7 @@ function TickerClaimControls({ spaceId: string; row: DebateClaim | null; entity: Entity | null; - onAnswered: (claimId: string) => void; + onAnswered: (claimId: string, position: boolean) => void; }) { const promptSignIn = usePrivySignIn(); const { @@ -316,17 +322,17 @@ function TickerClaimControls({ offersDebate: false, }); - const answered = control.viewerPosition !== null; + const position = control.viewerPosition; // Reported once, on the transition. The card stays up for the rest of its window so the viewer - // sees the split they just earned; it is the *next* seek past it that will skip it. + // sees the side they just took; it is the *next* seek past it that will skip it. const reported = React.useRef(false); React.useEffect(() => { - if (answered && !reported.current) { + if (position !== null && !reported.current) { reported.current = true; - onAnswered(claimId); + onAnswered(claimId, position); } - }, [answered, claimId, onAnswered]); + }, [position, claimId, onAnswered]); const copy = ENTITY_RESPONSE_COPY[responseKind]; diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 472ac811a0..decd45d83a 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -193,7 +193,7 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb stuck over two frozen faces. */} {ready && playbackEnded && (
- +
)} diff --git a/apps/web/core/debates/browse/debate-scorecard.tsx b/apps/web/core/debates/browse/debate-scorecard.tsx index e52e261018..2817fbf774 100644 --- a/apps/web/core/debates/browse/debate-scorecard.tsx +++ b/apps/web/core/debates/browse/debate-scorecard.tsx @@ -3,27 +3,45 @@ import * as React from 'react'; import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; -import type { DebateClaim } from '~/core/debates/api'; +import type { Debate, DebateClaim, DebateParticipant } from '~/core/debates/api'; import { type TimedClaim, formatTimecode } from '~/core/debates/claim-timing'; import { PositionRow, useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; +import type { DebateVotesResult } from '~/core/debates/use-debate-votes'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import type { Entity } from '~/core/types'; +import { Text } from '~/design-system/text'; + import type { DebateTicker } from './debate-claim-ticker'; +import { WinnerVoteButton } from './winner-vote-button'; + +/** How long a taken side stays on screen before the card moves on. */ +const ADVANCE_DELAY_MS = 700; /** * The ask, once the video has stopped. * - * During playback the claims are ambient — they float past and the icons are there if the viewer - * feels strongly about one. This is where taking a position is the actual request, so it gets the - * whole frame: the video is done, there is nothing left to obscure, and one claim at a time with - * labelled buttons is a different act from tapping a thumb at a passing line. + * Taking a position on a claim is what this whole feature is for, and this is where it gets asked + * properly: the video is done, nothing is moving, and one claim at a time gets the claim card's own + * pills rather than anything improvised. * - * It does not draw the winner vote. That already lives on each debater's tile and is visible at the - * same moment; a second copy here would be two controls publishing one vote. + * It ends on the debate's own question. Having gone through every claim, the viewer has just + * assembled their own answer to "who won" — so that vote belongs at the end of the sequence rather + * than only on the tiles behind the dimmed backdrop. */ -export function DebateScorecard({ ticker, onReplay }: { ticker: DebateTicker; onReplay: () => void }) { - const { claims, answered, rowsByClaimId, entitiesByClaimId, speakerByClaimId, onAnswered } = ticker; +export function DebateScorecard({ + debate, + ticker, + votes, + onReplay, +}: { + debate: Debate; + ticker: DebateTicker; + votes: DebateVotesResult; + onReplay: () => void; +}) { + const { claims, answered, answers, rowsByClaimId, entitiesByClaimId, speakerByClaimId, onAnswered } = ticker; const [skipped, setSkipped] = React.useState>(() => new Set()); @@ -37,6 +55,8 @@ export function DebateScorecard({ ticker, onReplay }: { ticker: DebateTicker; on setSkipped(current => new Set(current).add(claimId)); }, []); + const lean = useDebateLean(debate, claims, answers, speakerByClaimId); + // A debate with no claims has nothing to ask about, and the card would be an empty frame over // the replay button. if (answerable.length === 0) return null; @@ -47,12 +67,14 @@ export function DebateScorecard({ ticker, onReplay }: { ticker: DebateTicker; on
-

{next ? 'Where do you stand?' : 'That is all of them'}

-

+ + {next ? 'Where do you stand?' : 'That is all of them'} + + {next ? `${answerable.length} claims were made · ${answered.size} answered` - : `You answered ${answered.size} of ${answerable.length}. Now say who won.`} -

+ : `You answered ${answered.size} of ${answerable.length}`} +
+ ); +} + +type SpeakerLean = { participant: DebateParticipant; label: string; agreed: number; answered: number }; + +/** + * How the viewer's answers fell across the two debaters. + * + * Counted against the claims they actually answered, not against everything that debater said: "5 + * of 7" out of seven answered is a lean, while the same five out of a dozen claims they skipped + * would read as disagreement they never expressed. + */ +function useDebateLean( + debate: Debate, + claims: TimedClaim[], + answers: ReadonlyMap, + speakerByClaimId: Map +): SpeakerLean[] { + return React.useMemo(() => { + const byLabel = new Map(); + for (const participant of orderedParticipants(debate)) { + const label = speakerLabel(participant); + byLabel.set(label, { participant, label, agreed: 0, answered: 0 }); + } + + for (const claim of claims) { + const position = answers.get(claim.id); + if (position === undefined) continue; + const entry = byLabel.get(speakerByClaimId.get(claim.id) ?? ''); + if (!entry) continue; + entry.answered += 1; + if (position) entry.agreed += 1; + } + + return [...byLabel.values()]; + }, [debate, claims, answers, speakerByClaimId]); +} + +/** + * Where the viewer landed, and then the debate's own question. + * + * The lean is stated rather than scored, because the interesting part is not the arithmetic: a + * viewer who agreed with one debater's claims more often might still think the other argued the + * better debate. The winner vote sits right under it so they can say so. + */ +function DebateLean({ lean, votes }: { lean: SpeakerLean[]; votes: DebateVotesResult }) { + const answeredAny = lean.some(entry => entry.answered > 0); + const ranked = [...lean].sort((a, b) => b.agreed - a.agreed); + const leader = ranked[0]; + const runnerUp = ranked[1]; + const tied = Boolean(leader && runnerUp && leader.agreed === runnerUp.agreed); + + return ( +
+ {answeredAny ? ( + <> + + {tied ? 'You split evenly between them' : `You agreed with ${leader.label} most`} + +
+ {lean.map(entry => ( +
+ + {entry.label} + + + {entry.answered === 0 ? 'none answered' : `agreed with ${entry.agreed} of ${entry.answered}`} + +
+ ))} +
+ + ) : ( + + You skipped every claim + + )} + + + So who won the debate? + + {/* The same control as the tiles, driven by the same hook, so a vote cast here shows there + too rather than the two disagreeing. */} +
+ {lean.map(entry => ( + votes.castVote(entry.participant)} + /> + ))} +
); } @@ -84,9 +205,8 @@ export function DebateScorecard({ ticker, onReplay }: { ticker: DebateTicker; on /** * One claim, with both sides spelled out. * - * Labelled buttons here, unlike the floating lines: nothing is moving, the reader is being asked a - * question directly, and Verify/Dispute versus Agree/Disagree is what tells them which kind of - * question it is. + * The claim card's own pills, not a lookalike: this is the same question the explore feed, the hub + * and the claims panel ask, and there is room here for the full control. */ function ScorecardClaim({ claim, @@ -100,7 +220,7 @@ function ScorecardClaim({ speaker: string | null; row: DebateClaim | null; entity: Entity | null; - onAnswered: (claimId: string) => void; + onAnswered: (claimId: string, position: boolean) => void; onSkip: (claimId: string) => void; }) { const promptSignIn = usePrivySignIn(); @@ -126,23 +246,28 @@ function ScorecardClaim({ offersDebate: false, }); - // Advance as soon as the viewer answers, so the card walks itself rather than making them press - // a "next" after every claim. The response is already published by the time this fires. + const position = control.viewerPosition; + + // Held for a beat before moving on. Advancing the instant the response lands swaps the card out + // from under the press, so the pill never gets to show as taken and there is no way to tell the + // tap registered. The response itself is already published by then; this delays the card, not + // the write. const reported = React.useRef(false); React.useEffect(() => { - if (control.viewerPosition !== null && !reported.current) { - reported.current = true; - onAnswered(claim.id); - } - }, [control.viewerPosition, claim.id, onAnswered]); + if (position === null || reported.current) return; + reported.current = true; + + const timer = setTimeout(() => onAnswered(claim.id, position), ADVANCE_DELAY_MS); + return () => clearTimeout(timer); + }, [position, claim.id, onAnswered]); return (
-

+ {speaker ? `${speaker} said` : 'Said'} {claim.timing ? ` at ${formatTimecode(claim.timing.startMs)}` : null} -

+
-

{claim.text}

+ + {claim.text} + - {/* The claim card's own pills, not a lookalike. This is the same question the explore feed, - the hub and the claims panel ask, and there is room here for the full control — unlike the - floating lines, which are too narrow for it and use bare icons instead. */}
{control.responseError ? ( -

- {control.responseError} -

+
+ + {control.responseError} + +
) : null}
); From ab2130f799dc95703aae4b2790aa3a9ea103eeeb Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 15 Sep 2026 15:49:57 -0700 Subject: [PATCH 09/71] refactor(debates): hold the during-playback claim layer for its own PR (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claims surfacing over the video while it plays is the part of this that risks being a distraction rather than an addition, and it deserves to be judged on its own. It moves to a follow-up branched from this one; what stays here is the half that is uncontested — knowing when each claim was said, the markers on the scrubber, and the card at the end that asks for positions once there is no video left to interrupt. Removed here: the floating claim lines, their stack and fade, the icon buttons they carried, and the measured width that kept them inside the debater's name row. The subtitle goes back to where it was, since nothing shares that strip any more. Also drops scoreDebate, which was written for the end card and never used by it — the card counts per debater rather than splitting answered from unanswered. --- .../browse/debate-claim-ticker.test.tsx | 173 ---------- .../debates/browse/debate-claim-ticker.tsx | 321 +----------------- .../debates/browse/debate-feed-player.tsx | 73 +--- apps/web/core/debates/claim-ticker.test.ts | 13 +- apps/web/core/debates/claim-ticker.ts | 15 - 5 files changed, 19 insertions(+), 576 deletions(-) delete mode 100644 apps/web/core/debates/browse/debate-claim-ticker.test.tsx diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx deleted file mode 100644 index 782267561a..0000000000 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { TickerWindow } from '~/core/debates/claim-ticker'; -import type { TimedClaim } from '~/core/debates/claim-timing'; - -import { DebateClaimTickerCard } from './debate-claim-ticker'; - -const CLAIM_SPACE = '52c7ae149838b6d47ce0f3b2a5974546'; - -const mocks = vi.hoisted(() => ({ - /** What the shared position control reports the viewer currently holds — a side, not a string. */ - viewerPosition: null as boolean | null, - respond: vi.fn(), -})); - -vi.mock('~/core/claims/browse/claim-summary', () => ({ - ClaimSummary: () =>
, -})); - -vi.mock('~/core/claims/browse/use-claim-response-state', () => ({ - useClaimResponseState: () => ({ - responseKind: 'stance', - isResponseKindResolved: true, - isViewerResponseResolved: true, - responseBlockedReason: null, - summary: { isLoading: false }, - claim: { id: 'claim-1' }, - positions: [], - readiness: {}, - }), -})); - -vi.mock('~/core/debates/matchmaking/matchmaking-claim-card', () => ({ - PositionRow: ({ onRespond, disabled }: { onRespond: (side: string) => void; disabled?: boolean }) => ( - - ), - useClaimPositionControl: () => ({ - viewerPosition: mocks.viewerPosition, - optimisticPositions: [], - respond: mocks.respond, - actionTitle: () => '', - responseError: null, - canRespond: true, - }), -})); - -vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() })); - -function claim(overrides: Partial = {}): TimedClaim { - return { - id: 'claim-1', - text: 'The Supreme Court is no longer providing sufficient checks on executive power', - spaceId: CLAIM_SPACE, - blockId: 'block-1', - publishedTiming: null, - timing: { startMs: 134_600, endMs: 143_140, confidence: 1, source: 'published' }, - ...overrides, - }; -} - -function window(overrides: Partial = {}): TickerWindow { - return { claim: claim(overrides), startMs: 134_600, endMs: 148_140 }; -} - -function renderCard(props: Partial> = {}) { - return render( - - ); -} - -beforeEach(() => { - mocks.viewerPosition = null; - mocks.respond.mockClear(); -}); - -afterEach(cleanup); - -describe('DebateClaimTickerCard', () => { - it('quotes the claim', () => { - renderCard(); - - expect(screen.getByText(/Supreme Court is no longer providing/)).toBeInTheDocument(); - }); - - // It sits in the debater's own corner, above their name, so repeating the name on the card is - // noise in the smallest space on the screen. - it('does not repeat the speaker, who is named directly below it', () => { - renderCard(); - - expect(screen.queryByText(/just said/)).not.toBeInTheDocument(); - }); - - it('fades with its window rather than holding at full strength', () => { - const { container } = renderCard({ opacity: 0.4 }); - - expect(container.firstElementChild).toHaveStyle({ opacity: '0.4' }); - }); - - // The line has to stay a line. A split appearing under it mid-playback would resize the card - // while the reader is looking at it, and the numbers have a home on the end-of-debate card. - it('never grows a crowd split, answered or not', () => { - renderCard(); - expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); - - cleanup(); - mocks.viewerPosition = true; - renderCard(); - expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); - }); - - // Small, recessive, and unlabelled on screen — so the accessible name is the only thing telling - // a screen reader which side is which. - it('offers both sides as icons that still name themselves', () => { - renderCard(); - - expect(screen.getByLabelText('Agree')).toBeInTheDocument(); - expect(screen.getByLabelText('Disagree')).toBeInTheDocument(); - }); - - it('shows which side the viewer took', () => { - mocks.viewerPosition = true; - - renderCard(); - - expect(screen.getByLabelText('Agree')).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByLabelText('Disagree')).toHaveAttribute('aria-pressed', 'false'); - }); - - it('reports the answer up so the card is not asked again on a rewind', () => { - mocks.viewerPosition = true; - const onAnswered = vi.fn(); - - renderCard({ onAnswered }); - - // Reports which way, not just that — the end card tallies the sides. - expect(onAnswered).toHaveBeenCalledExactlyOnceWith('claim-1', true); - }); - - it('does not report an answer for a claim the viewer has not taken a side on', () => { - const onAnswered = vi.fn(); - - renderCard({ onAnswered }); - - expect(onAnswered).not.toHaveBeenCalled(); - }); - - // The video behind the card is one large play/pause button. - it('does not toggle playback when the card itself is clicked', () => { - const onToggle = vi.fn(); - - render( - - ); - fireEvent.click(screen.getByText(/Supreme Court is no longer providing/)); - - expect(onToggle).not.toHaveBeenCalled(); - }); - - // A claim the graph reports no space for cannot be linked or responded to, so the panel renders - // it as plain text. Over the video there is nothing useful to draw at all. - it('draws nothing for a claim with no space to publish a response to', () => { - const { container } = renderCard({ window: { ...window({ spaceId: null }) } }); - - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 09674d4f74..98eef2b85d 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -4,41 +4,19 @@ import * as React from 'react'; import cx from 'classnames'; -import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { Debate, DebateClaim } from '~/core/debates/api'; -import { - type ClaimMarker, - type StackedCard, - type TickerWindow, - claimMarkers, - tickerStack, - tickerWindows, -} from '~/core/debates/claim-ticker'; +import { type ClaimMarker, claimMarkers } from '~/core/debates/claim-ticker'; import { type TimedClaim, claimsInSpokenOrder } from '~/core/debates/claim-timing'; import { useDebateClaimsBySpaces } from '~/core/debates/hooks'; -import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; -import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { uuidToHex } from '~/core/id/normalize'; -import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; -import { ChevronDown } from '~/design-system/icons/chevron-down'; -import { ChevronUp } from '~/design-system/icons/chevron-up'; -import { ThumbDown } from '~/design-system/icons/thumb-down'; -import { ThumbUp } from '~/design-system/icons/thumb-up'; export type DebateTicker = { - /** - * The cards to draw over each debater right now, oldest first, keyed by participant slot. - * - * Split by slot because a card belongs over the person who said it: the corner it sits in is - * their corner, above their name. - */ - stacks: Map; /** Every precisely-placed claim, for the scrubber. */ markers: ClaimMarker[]; /** Claims in the order they were said, for the card at the end. */ @@ -48,7 +26,6 @@ export type DebateTicker = { /** Which way they answered each one, for the tally at the end. */ answers: ReadonlyMap; onAnswered: (claimId: string, position: boolean) => void; - onDismiss: (claimId: string) => void; /** Per-claim lookups, hoisted so the card and the end-of-debate stack share one batch. */ rowsByClaimId: Map; entitiesByClaimId: Map; @@ -63,27 +40,20 @@ export type DebateTicker = { * key the feed's count badge and the Claims panel already use, and the transcript on the same key * `useDebatePlayback` fetches for subtitles. On a card that is playing, this costs nothing. */ -export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled: boolean): DebateTicker { +export function useDebateClaimTicker(debate: Debate, enabled: boolean): DebateTicker { const { claims } = useDebateTranscriptClaims(debate.id, debate.claim.space_id, enabled); const { timings } = useClaimTimings(debate.id, claims, enabled); const [answers, setAnswers] = React.useState>(() => new Map()); - const [dismissed, setDismissed] = React.useState>(() => new Set()); - const onAnswered = React.useCallback((claimId: string, position: boolean) => { setAnswers(current => new Map(current).set(claimId, position)); - setDismissed(current => new Set(current).add(claimId)); }, []); // The ids alone, for every caller that only asks "has this been answered". const answered = React.useMemo(() => new Set(answers.keys()), [answers]); - const onDismiss = React.useCallback((claimId: string) => { - setDismissed(current => new Set(current).add(claimId)); - }, []); const timedClaims = React.useMemo(() => claimsInSpokenOrder(claims.all, timings), [claims.all, timings]); - const windows = React.useMemo(() => tickerWindows(timedClaims), [timedClaims]); // The debate's own length rather than the video element's: the two recordings are composited // against the debate timeline, which is what the scrubber measures. @@ -128,313 +98,38 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled // Attribution rides the *block*, not the claim: a claim's own space is the debate's publication // space, which both debaters share. The block's `Authors` relation points at the speaker's // personal space, which is the id the participant list keys on. - const { speakerByClaimId, slotByClaimId } = React.useMemo(() => { - const bySpace = new Map(); + const speakerByClaimId = React.useMemo(() => { + const bySpace = new Map(); for (const participant of orderedParticipants(debate)) { - bySpace.set(uuidToHex(participant.profile_space_id), { - label: speakerLabel(participant), - slot: participant.participant_slot, - }); + bySpace.set(uuidToHex(participant.profile_space_id), { label: speakerLabel(participant) }); } - const byBlock = new Map(); + const byBlock = new Map(); for (const block of claims.blocks) { const speaker = block.authorSpaceId ? bySpace.get(uuidToHex(block.authorSpaceId)) : undefined; if (speaker) byBlock.set(block.id, speaker); } const labels = new Map(); - const slots = new Map(); for (const claim of claims.all) { const speaker = byBlock.get(claim.blockId); - if (!speaker) continue; - labels.set(claim.id, speaker.label); - slots.set(claim.id, speaker.slot); + if (speaker) labels.set(claim.id, speaker.label); } - return { speakerByClaimId: labels, slotByClaimId: slots }; + return labels; }, [claims.all, claims.blocks, debate]); - // One stack per debater. A claim whose speaker could not be resolved — attribution and the - // participant list can disagree — is left out rather than parked over whichever tile: putting a - // claim over the wrong face is the misquote this whole layer is careful about. - const stacks = React.useMemo(() => { - const bySlot = new Map(); - if (!enabled) return bySlot; - - for (const card of tickerStack(windows, playheadMs, dismissed)) { - const slot = slotByClaimId.get(card.window.claim.id); - if (slot === undefined) continue; - const existing = bySlot.get(slot); - if (existing) existing.push(card); - else bySlot.set(slot, [card]); - } - return bySlot; - }, [enabled, windows, playheadMs, dismissed, slotByClaimId]); - return { - stacks, markers, claims: timedClaims, answered, answers, onAnswered, - onDismiss, rowsByClaimId, entitiesByClaimId, speakerByClaimId, }; } -/** - * The claim card that rises over the video as it is said. - * - * Sits in the seam between the two tiles, which is the one strip of the player that is never a - * face. One card at a time, dismissible, and it never pauses the video or opens a dialog — the - * only thing that interrupts playback is the sign-in prompt, which is the app's standard prompt - * and only appears if the viewer presses a pill while signed out. - */ -export function DebateClaimTickerCard({ - window, - opacity = 1, - row, - entity, - onAnswered, -}: { - window: TickerWindow; - /** Driven by the playhead, so a scrub lands on the right strength rather than mid-animation. */ - opacity?: number; - row: DebateClaim | null; - entity: Entity | null; - onAnswered: (claimId: string, position: boolean) => void; -}) { - const { claim } = window; - - if (!claim.spaceId) return null; - - return ( -
event.stopPropagation()} - style={{ opacity }} - className="pointer-events-auto flex w-full flex-col gap-0.5 rounded bg-black/65 px-2 py-1.5 backdrop-blur-[2px]" - > - {claim.text} - -
- ); -} - -/** - * The stack of claim cards over one debater, oldest at the top. - * - * Anchored above the name in the corner rather than centred over the video: a card in the middle - * reads as a dialog demanding an answer, and it covers the face of the person making the argument. - * Here it behaves like a chat — the newest arrives at the bottom, earlier ones ride up and fade. - */ -export function DebateClaimTickerStack({ - cards, - maxWidth, - rowsByClaimId, - entitiesByClaimId, - onAnswered, -}: { - cards: StackedCard[]; - /** The name row's width, so a line never runs past the debater's position chip. */ - maxWidth?: number | null; - rowsByClaimId: Map; - entitiesByClaimId: Map; - onAnswered: (claimId: string, position: boolean) => void; -}) { - if (cards.length === 0) return null; - - return ( -
- {cards.map(card => ( - - ))} -
- ); -} - - -/** - * The same control logic and the same publish path as every other claim surface, drawn small. - * - * `PositionRow` is not reused here, and the reason is size rather than taste: it is a container - * query that stacks its two pills vertically below ~230px, which is exactly the width this card - * wants to be. Reusing it would force the card wide enough to cover the face it sits beside. What - * matters is shared underneath — `useClaimPositionControl` publishes the response, and the labels - * come from the same vocabulary table, so a factual claim still reads Verify/Dispute here. - * - * The crowd split is deliberately withheld until the viewer has answered. Showing it first biases - * the answer and makes the tally a measure of itself; withholding it also gives the tap a payoff. - */ -function TickerClaimControls({ - claimId, - spaceId, - row, - entity, - onAnswered, -}: { - claimId: string; - spaceId: string; - row: DebateClaim | null; - entity: Entity | null; - onAnswered: (claimId: string, position: boolean) => void; -}) { - const promptSignIn = usePrivySignIn(); - const { - responseKind, - isResponseKindResolved, - isViewerResponseResolved, - responseBlockedReason, - claim, - positions, - readiness, - } = useClaimResponseState({ claimId, spaceId, row, entity }); - - const control = useClaimPositionControl({ - claim, - positions, - readiness, - answersReady: isResponseKindResolved && isViewerResponseResolved, - responseBlockedReason, - onRequireSignIn: promptSignIn, - // Same reason the panel passes false: the viewer is already watching this debate, so offering - // them another one is the wrong invitation at the wrong moment. - offersDebate: false, - }); - - const position = control.viewerPosition; - - // Reported once, on the transition. The card stays up for the rest of its window so the viewer - // sees the side they just took; it is the *next* seek past it that will skip it. - const reported = React.useRef(false); - React.useEffect(() => { - if (position !== null && !reported.current) { - reported.current = true; - onAnswered(claimId, position); - } - }, [position, claimId, onAnswered]); - - const copy = ENTITY_RESPONSE_COPY[responseKind]; - - return ( - - control.respond(true)} - /> - control.respond(false)} - /> - {/* No crowd split and no error text here. Both would make the card grow while the reader is - part-way through it; the end-of-debate card is where the numbers live. */} - - ); -} - -/** - * One side of the answer, as an icon rather than a labelled pill. - * - * A labelled button turns the line into a form. An icon keeps the line a line — the reader takes - * in what was said, and the affordance is there in the corner of their eye if they feel strongly - * about it. The label survives as the accessible name and the tooltip, so nothing is lost to - * anyone reading it aloud or hovering. - * - * Thumbs for a stance claim, chevrons for a factual one, which is the split the rest of the app - * already draws: agreeing with a position and verifying a fact are different acts, and a thumb on - * "the SEC sued Coinbase" reads as approval rather than confirmation. - */ -export function ClaimIconButton({ - responseKind, - position, - label, - selected, - disabled, - title, - surface = 'video', - size = 'sm', - onClick, -}: { - responseKind: 'stance' | 'veracity' | 'curation'; - position: boolean; - label: string; - selected: boolean; - disabled: boolean; - title: string; - /** `video` sits on the dark scrim over a frame; `card` on white. */ - surface?: 'video' | 'card'; - size?: 'sm' | 'md'; - onClick?: () => void; -}) { - const Icon = responseKind === 'veracity' ? (position ? ChevronUp : ChevronDown) : position ? ThumbUp : ThumbDown; - - return ( - - ); -} - /** Claim markers on the scrubber, each a place the viewer can jump to. */ export function ClaimScrubberMarkers({ markers, diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index decd45d83a..0b71899a2b 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -18,12 +18,11 @@ import { Avatar } from '~/design-system/avatar'; import { RetrySmall } from '~/design-system/icons/retry-small'; import { Text } from '~/design-system/text'; -import { ClaimScrubberMarkers, DebateClaimTickerStack, useDebateClaimTicker } from './debate-claim-ticker'; +import { ClaimScrubberMarkers, useDebateClaimTicker } from './debate-claim-ticker'; import { DebateScorecard } from './debate-scorecard'; import { Play, Speaker, SpeakerMuted } from './icons'; import { WinnerVoteButton } from './winner-vote-button'; -import type { ClaimMarker, StackedCard } from '~/core/debates/claim-ticker'; -import type { DebateTicker } from './debate-claim-ticker'; +import type { ClaimMarker } from '~/core/debates/claim-ticker'; type DebateFeedPlayerProps = { debate: Debate; @@ -99,9 +98,9 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } }, [active, isScrubbing, playbackEnded, playing, ready, resumeBoth, suspend, userPaused]); - // The live claim layer. Loaded alongside the recordings so a card is ready the moment the claim - // it belongs to is spoken, rather than appearing a beat late on the first one. - const ticker = useDebateClaimTicker(debate, playheadSeconds * 1000, active || preload); + // Claims, their timecodes and the viewer's answers, for the scrubber markers and the card at + // the end. Loaded alongside the recordings so neither waits on the other. + const ticker = useDebateClaimTicker(debate, active || preload); const showControls = ready && (userPaused || (playbackEnded && !hasVoted)); // End of an unvoted debate offers a replay; a user pause shows the paused glyph. @@ -117,8 +116,6 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb audible={playing && turnState?.slot === 1} countdown={playing && turnState?.slot === 1 ? turnState : null} subtitle={activeSlot === 1 ? subtitle : null} - claims={playbackEnded ? [] : (ticker.stacks.get(1) ?? [])} - ticker={ticker} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} @@ -157,8 +154,6 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb audible={playing && turnState?.slot === 2} countdown={playing && turnState?.slot === 2 ? turnState : null} subtitle={activeSlot === 2 ? subtitle : null} - claims={playbackEnded ? [] : (ticker.stacks.get(2) ?? [])} - ticker={ticker} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} @@ -224,8 +219,6 @@ function DebaterVideo({ audible, countdown, subtitle, - claims, - ticker, mutedByUser, isResuming, onPlaybackTick, @@ -240,9 +233,6 @@ function DebaterVideo({ audible: boolean; countdown: TurnState; subtitle: string | null; - /** The claims this debater is making right now, oldest first. */ - claims: StackedCard[]; - ticker: DebateTicker; mutedByUser: boolean; isResuming: boolean; onPlaybackTick: () => void; @@ -253,8 +243,6 @@ function DebaterVideo({ }) { const { openSidePanel } = useEntitySidePanel(); const name = participant ? speakerLabel(participant) : 'Debater'; - const identityRef = React.useRef(null); - const claimWidth = useIdentityRowWidth(identityRef); const muted = !audible || mutedByUser; @@ -338,32 +326,16 @@ function DebaterVideo({ {countdown && } - {/* Claims and the subtitle share one bottom-anchored column so they cannot land on top of - each other — they did, because both wanted the strip above the debater's name. The - subtitle stays pinned at the bottom of the column and the claims rise above it. */} - {(subtitle || claims.length > 0) && ( -
- {/* Capped at the width of the name row below, so a claim never runs out past the - debater's position chip. Measured rather than guessed: the row is as wide as the - name, and names vary. */} - - {subtitle && ( - - {subtitle} - - )} + {subtitle && ( +
+ + {subtitle} +
)} {/* Debater identity: avatar + name + position, opens their personal space in the side panel. */} + ), + useClaimPositionControl: () => ({ + viewerPosition: mocks.viewerPosition, + optimisticPositions: [], + respond: mocks.respond, + actionTitle: () => '', + responseError: null, + canRespond: true, + }), +})); + +vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() })); + +function claim(overrides: Partial = {}): TimedClaim { + return { + id: 'claim-1', + text: 'The Supreme Court is no longer providing sufficient checks on executive power', + spaceId: CLAIM_SPACE, + blockId: 'block-1', + publishedTiming: null, + timing: { startMs: 134_600, endMs: 143_140, confidence: 1, source: 'published' }, + ...overrides, + }; +} + +function window(overrides: Partial = {}): TickerWindow { + return { claim: claim(overrides), startMs: 134_600, endMs: 148_140 }; +} + +function renderCard(props: Partial> = {}) { + return render( + + ); +} + +beforeEach(() => { + mocks.viewerPosition = null; + mocks.respond.mockClear(); +}); + +afterEach(cleanup); + +describe('DebateClaimTickerCard', () => { + it('quotes the claim', () => { + renderCard(); + + expect(screen.getByText(/Supreme Court is no longer providing/)).toBeInTheDocument(); + }); + + // It sits in the debater's own corner, above their name, so repeating the name on the card is + // noise in the smallest space on the screen. + it('does not repeat the speaker, who is named directly below it', () => { + renderCard(); + + expect(screen.queryByText(/just said/)).not.toBeInTheDocument(); + }); + + it('fades with its window rather than holding at full strength', () => { + const { container } = renderCard({ opacity: 0.4 }); + + expect(container.firstElementChild).toHaveStyle({ opacity: '0.4' }); + }); + + // The line has to stay a line. A split appearing under it mid-playback would resize the card + // while the reader is looking at it, and the numbers have a home on the end-of-debate card. + it('never grows a crowd split, answered or not', () => { + renderCard(); + expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); + + cleanup(); + mocks.viewerPosition = true; + renderCard(); + expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); + }); + + // Small, recessive, and unlabelled on screen — so the accessible name is the only thing telling + // a screen reader which side is which. + it('offers both sides as icons that still name themselves', () => { + renderCard(); + + expect(screen.getByLabelText('Agree')).toBeInTheDocument(); + expect(screen.getByLabelText('Disagree')).toBeInTheDocument(); + }); + + it('shows which side the viewer took', () => { + mocks.viewerPosition = true; + + renderCard(); + + expect(screen.getByLabelText('Agree')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByLabelText('Disagree')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('reports the answer up so the card is not asked again on a rewind', () => { + mocks.viewerPosition = true; + const onAnswered = vi.fn(); + + renderCard({ onAnswered }); + + // Reports which way, not just that — the end card tallies the sides. + expect(onAnswered).toHaveBeenCalledExactlyOnceWith('claim-1', true); + }); + + it('does not report an answer for a claim the viewer has not taken a side on', () => { + const onAnswered = vi.fn(); + + renderCard({ onAnswered }); + + expect(onAnswered).not.toHaveBeenCalled(); + }); + + // The video behind the card is one large play/pause button. + it('does not toggle playback when the card itself is clicked', () => { + const onToggle = vi.fn(); + + render( + + ); + fireEvent.click(screen.getByText(/Supreme Court is no longer providing/)); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + // A claim the graph reports no space for cannot be linked or responded to, so the panel renders + // it as plain text. Over the video there is nothing useful to draw at all. + it('draws nothing for a claim with no space to publish a response to', () => { + const { container } = renderCard({ window: { ...window({ spaceId: null }) } }); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 98eef2b85d..09674d4f74 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -4,19 +4,41 @@ import * as React from 'react'; import cx from 'classnames'; +import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { Debate, DebateClaim } from '~/core/debates/api'; -import { type ClaimMarker, claimMarkers } from '~/core/debates/claim-ticker'; +import { + type ClaimMarker, + type StackedCard, + type TickerWindow, + claimMarkers, + tickerStack, + tickerWindows, +} from '~/core/debates/claim-ticker'; import { type TimedClaim, claimsInSpokenOrder } from '~/core/debates/claim-timing'; import { useDebateClaimsBySpaces } from '~/core/debates/hooks'; +import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; +import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { uuidToHex } from '~/core/id/normalize'; +import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; +import { ChevronDown } from '~/design-system/icons/chevron-down'; +import { ChevronUp } from '~/design-system/icons/chevron-up'; +import { ThumbDown } from '~/design-system/icons/thumb-down'; +import { ThumbUp } from '~/design-system/icons/thumb-up'; export type DebateTicker = { + /** + * The cards to draw over each debater right now, oldest first, keyed by participant slot. + * + * Split by slot because a card belongs over the person who said it: the corner it sits in is + * their corner, above their name. + */ + stacks: Map; /** Every precisely-placed claim, for the scrubber. */ markers: ClaimMarker[]; /** Claims in the order they were said, for the card at the end. */ @@ -26,6 +48,7 @@ export type DebateTicker = { /** Which way they answered each one, for the tally at the end. */ answers: ReadonlyMap; onAnswered: (claimId: string, position: boolean) => void; + onDismiss: (claimId: string) => void; /** Per-claim lookups, hoisted so the card and the end-of-debate stack share one batch. */ rowsByClaimId: Map; entitiesByClaimId: Map; @@ -40,20 +63,27 @@ export type DebateTicker = { * key the feed's count badge and the Claims panel already use, and the transcript on the same key * `useDebatePlayback` fetches for subtitles. On a card that is playing, this costs nothing. */ -export function useDebateClaimTicker(debate: Debate, enabled: boolean): DebateTicker { +export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled: boolean): DebateTicker { const { claims } = useDebateTranscriptClaims(debate.id, debate.claim.space_id, enabled); const { timings } = useClaimTimings(debate.id, claims, enabled); const [answers, setAnswers] = React.useState>(() => new Map()); + const [dismissed, setDismissed] = React.useState>(() => new Set()); + const onAnswered = React.useCallback((claimId: string, position: boolean) => { setAnswers(current => new Map(current).set(claimId, position)); + setDismissed(current => new Set(current).add(claimId)); }, []); // The ids alone, for every caller that only asks "has this been answered". const answered = React.useMemo(() => new Set(answers.keys()), [answers]); + const onDismiss = React.useCallback((claimId: string) => { + setDismissed(current => new Set(current).add(claimId)); + }, []); const timedClaims = React.useMemo(() => claimsInSpokenOrder(claims.all, timings), [claims.all, timings]); + const windows = React.useMemo(() => tickerWindows(timedClaims), [timedClaims]); // The debate's own length rather than the video element's: the two recordings are composited // against the debate timeline, which is what the scrubber measures. @@ -98,38 +128,313 @@ export function useDebateClaimTicker(debate: Debate, enabled: boolean): DebateTi // Attribution rides the *block*, not the claim: a claim's own space is the debate's publication // space, which both debaters share. The block's `Authors` relation points at the speaker's // personal space, which is the id the participant list keys on. - const speakerByClaimId = React.useMemo(() => { - const bySpace = new Map(); + const { speakerByClaimId, slotByClaimId } = React.useMemo(() => { + const bySpace = new Map(); for (const participant of orderedParticipants(debate)) { - bySpace.set(uuidToHex(participant.profile_space_id), { label: speakerLabel(participant) }); + bySpace.set(uuidToHex(participant.profile_space_id), { + label: speakerLabel(participant), + slot: participant.participant_slot, + }); } - const byBlock = new Map(); + const byBlock = new Map(); for (const block of claims.blocks) { const speaker = block.authorSpaceId ? bySpace.get(uuidToHex(block.authorSpaceId)) : undefined; if (speaker) byBlock.set(block.id, speaker); } const labels = new Map(); + const slots = new Map(); for (const claim of claims.all) { const speaker = byBlock.get(claim.blockId); - if (speaker) labels.set(claim.id, speaker.label); + if (!speaker) continue; + labels.set(claim.id, speaker.label); + slots.set(claim.id, speaker.slot); } - return labels; + return { speakerByClaimId: labels, slotByClaimId: slots }; }, [claims.all, claims.blocks, debate]); + // One stack per debater. A claim whose speaker could not be resolved — attribution and the + // participant list can disagree — is left out rather than parked over whichever tile: putting a + // claim over the wrong face is the misquote this whole layer is careful about. + const stacks = React.useMemo(() => { + const bySlot = new Map(); + if (!enabled) return bySlot; + + for (const card of tickerStack(windows, playheadMs, dismissed)) { + const slot = slotByClaimId.get(card.window.claim.id); + if (slot === undefined) continue; + const existing = bySlot.get(slot); + if (existing) existing.push(card); + else bySlot.set(slot, [card]); + } + return bySlot; + }, [enabled, windows, playheadMs, dismissed, slotByClaimId]); + return { + stacks, markers, claims: timedClaims, answered, answers, onAnswered, + onDismiss, rowsByClaimId, entitiesByClaimId, speakerByClaimId, }; } +/** + * The claim card that rises over the video as it is said. + * + * Sits in the seam between the two tiles, which is the one strip of the player that is never a + * face. One card at a time, dismissible, and it never pauses the video or opens a dialog — the + * only thing that interrupts playback is the sign-in prompt, which is the app's standard prompt + * and only appears if the viewer presses a pill while signed out. + */ +export function DebateClaimTickerCard({ + window, + opacity = 1, + row, + entity, + onAnswered, +}: { + window: TickerWindow; + /** Driven by the playhead, so a scrub lands on the right strength rather than mid-animation. */ + opacity?: number; + row: DebateClaim | null; + entity: Entity | null; + onAnswered: (claimId: string, position: boolean) => void; +}) { + const { claim } = window; + + if (!claim.spaceId) return null; + + return ( +
event.stopPropagation()} + style={{ opacity }} + className="pointer-events-auto flex w-full flex-col gap-0.5 rounded bg-black/65 px-2 py-1.5 backdrop-blur-[2px]" + > + {claim.text} + +
+ ); +} + +/** + * The stack of claim cards over one debater, oldest at the top. + * + * Anchored above the name in the corner rather than centred over the video: a card in the middle + * reads as a dialog demanding an answer, and it covers the face of the person making the argument. + * Here it behaves like a chat — the newest arrives at the bottom, earlier ones ride up and fade. + */ +export function DebateClaimTickerStack({ + cards, + maxWidth, + rowsByClaimId, + entitiesByClaimId, + onAnswered, +}: { + cards: StackedCard[]; + /** The name row's width, so a line never runs past the debater's position chip. */ + maxWidth?: number | null; + rowsByClaimId: Map; + entitiesByClaimId: Map; + onAnswered: (claimId: string, position: boolean) => void; +}) { + if (cards.length === 0) return null; + + return ( +
+ {cards.map(card => ( + + ))} +
+ ); +} + + +/** + * The same control logic and the same publish path as every other claim surface, drawn small. + * + * `PositionRow` is not reused here, and the reason is size rather than taste: it is a container + * query that stacks its two pills vertically below ~230px, which is exactly the width this card + * wants to be. Reusing it would force the card wide enough to cover the face it sits beside. What + * matters is shared underneath — `useClaimPositionControl` publishes the response, and the labels + * come from the same vocabulary table, so a factual claim still reads Verify/Dispute here. + * + * The crowd split is deliberately withheld until the viewer has answered. Showing it first biases + * the answer and makes the tally a measure of itself; withholding it also gives the tap a payoff. + */ +function TickerClaimControls({ + claimId, + spaceId, + row, + entity, + onAnswered, +}: { + claimId: string; + spaceId: string; + row: DebateClaim | null; + entity: Entity | null; + onAnswered: (claimId: string, position: boolean) => void; +}) { + const promptSignIn = usePrivySignIn(); + const { + responseKind, + isResponseKindResolved, + isViewerResponseResolved, + responseBlockedReason, + claim, + positions, + readiness, + } = useClaimResponseState({ claimId, spaceId, row, entity }); + + const control = useClaimPositionControl({ + claim, + positions, + readiness, + answersReady: isResponseKindResolved && isViewerResponseResolved, + responseBlockedReason, + onRequireSignIn: promptSignIn, + // Same reason the panel passes false: the viewer is already watching this debate, so offering + // them another one is the wrong invitation at the wrong moment. + offersDebate: false, + }); + + const position = control.viewerPosition; + + // Reported once, on the transition. The card stays up for the rest of its window so the viewer + // sees the side they just took; it is the *next* seek past it that will skip it. + const reported = React.useRef(false); + React.useEffect(() => { + if (position !== null && !reported.current) { + reported.current = true; + onAnswered(claimId, position); + } + }, [position, claimId, onAnswered]); + + const copy = ENTITY_RESPONSE_COPY[responseKind]; + + return ( + + control.respond(true)} + /> + control.respond(false)} + /> + {/* No crowd split and no error text here. Both would make the card grow while the reader is + part-way through it; the end-of-debate card is where the numbers live. */} + + ); +} + +/** + * One side of the answer, as an icon rather than a labelled pill. + * + * A labelled button turns the line into a form. An icon keeps the line a line — the reader takes + * in what was said, and the affordance is there in the corner of their eye if they feel strongly + * about it. The label survives as the accessible name and the tooltip, so nothing is lost to + * anyone reading it aloud or hovering. + * + * Thumbs for a stance claim, chevrons for a factual one, which is the split the rest of the app + * already draws: agreeing with a position and verifying a fact are different acts, and a thumb on + * "the SEC sued Coinbase" reads as approval rather than confirmation. + */ +export function ClaimIconButton({ + responseKind, + position, + label, + selected, + disabled, + title, + surface = 'video', + size = 'sm', + onClick, +}: { + responseKind: 'stance' | 'veracity' | 'curation'; + position: boolean; + label: string; + selected: boolean; + disabled: boolean; + title: string; + /** `video` sits on the dark scrim over a frame; `card` on white. */ + surface?: 'video' | 'card'; + size?: 'sm' | 'md'; + onClick?: () => void; +}) { + const Icon = responseKind === 'veracity' ? (position ? ChevronUp : ChevronDown) : position ? ThumbUp : ThumbDown; + + return ( + + ); +} + /** Claim markers on the scrubber, each a place the viewer can jump to. */ export function ClaimScrubberMarkers({ markers, diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 0b71899a2b..decd45d83a 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -18,11 +18,12 @@ import { Avatar } from '~/design-system/avatar'; import { RetrySmall } from '~/design-system/icons/retry-small'; import { Text } from '~/design-system/text'; -import { ClaimScrubberMarkers, useDebateClaimTicker } from './debate-claim-ticker'; +import { ClaimScrubberMarkers, DebateClaimTickerStack, useDebateClaimTicker } from './debate-claim-ticker'; import { DebateScorecard } from './debate-scorecard'; import { Play, Speaker, SpeakerMuted } from './icons'; import { WinnerVoteButton } from './winner-vote-button'; -import type { ClaimMarker } from '~/core/debates/claim-ticker'; +import type { ClaimMarker, StackedCard } from '~/core/debates/claim-ticker'; +import type { DebateTicker } from './debate-claim-ticker'; type DebateFeedPlayerProps = { debate: Debate; @@ -98,9 +99,9 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } }, [active, isScrubbing, playbackEnded, playing, ready, resumeBoth, suspend, userPaused]); - // Claims, their timecodes and the viewer's answers, for the scrubber markers and the card at - // the end. Loaded alongside the recordings so neither waits on the other. - const ticker = useDebateClaimTicker(debate, active || preload); + // The live claim layer. Loaded alongside the recordings so a card is ready the moment the claim + // it belongs to is spoken, rather than appearing a beat late on the first one. + const ticker = useDebateClaimTicker(debate, playheadSeconds * 1000, active || preload); const showControls = ready && (userPaused || (playbackEnded && !hasVoted)); // End of an unvoted debate offers a replay; a user pause shows the paused glyph. @@ -116,6 +117,8 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb audible={playing && turnState?.slot === 1} countdown={playing && turnState?.slot === 1 ? turnState : null} subtitle={activeSlot === 1 ? subtitle : null} + claims={playbackEnded ? [] : (ticker.stacks.get(1) ?? [])} + ticker={ticker} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} @@ -154,6 +157,8 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb audible={playing && turnState?.slot === 2} countdown={playing && turnState?.slot === 2 ? turnState : null} subtitle={activeSlot === 2 ? subtitle : null} + claims={playbackEnded ? [] : (ticker.stacks.get(2) ?? [])} + ticker={ticker} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} @@ -219,6 +224,8 @@ function DebaterVideo({ audible, countdown, subtitle, + claims, + ticker, mutedByUser, isResuming, onPlaybackTick, @@ -233,6 +240,9 @@ function DebaterVideo({ audible: boolean; countdown: TurnState; subtitle: string | null; + /** The claims this debater is making right now, oldest first. */ + claims: StackedCard[]; + ticker: DebateTicker; mutedByUser: boolean; isResuming: boolean; onPlaybackTick: () => void; @@ -243,6 +253,8 @@ function DebaterVideo({ }) { const { openSidePanel } = useEntitySidePanel(); const name = participant ? speakerLabel(participant) : 'Debater'; + const identityRef = React.useRef(null); + const claimWidth = useIdentityRowWidth(identityRef); const muted = !audible || mutedByUser; @@ -326,16 +338,32 @@ function DebaterVideo({ {countdown && } - {subtitle && ( -
- - {subtitle} - + {/* Claims and the subtitle share one bottom-anchored column so they cannot land on top of + each other — they did, because both wanted the strip above the debater's name. The + subtitle stays pinned at the bottom of the column and the claims rise above it. */} + {(subtitle || claims.length > 0) && ( +
+ {/* Capped at the width of the name row below, so a claim never runs out past the + debater's position chip. Measured rather than guessed: the row is as wide as the + name, and names vary. */} + + {subtitle && ( + + {subtitle} + + )}
)} {/* Debater identity: avatar + name + position, opens their personal space in the side panel. */} + ); +} + /** * One claim, with both sides spelled out. * @@ -248,15 +377,29 @@ function ScorecardClaim({ const position = control.viewerPosition; - // Held for a beat before moving on. Advancing the instant the response lands swaps the card out - // from under the press, so the pill never gets to show as taken and there is no way to tell the - // tap registered. The response itself is already published by then; this delays the card, not - // the write. + /** + * Whether this card was already answered when it mounted. + * + * The caller filters those out using the rows it batched, but that only covers spaces geo-chat + * indexes — elsewhere the answer only turns up once this component asks. Such a claim is reported + * immediately rather than after the hold: the hold exists so a fresh press is seen landing, and + * there is nothing to see on one answered weeks ago. + */ + const wasAnsweredOnMount = React.useRef(null); + if (wasAnsweredOnMount.current === null && isViewerResponseResolved) { + wasAnsweredOnMount.current = position !== null; + } + const reported = React.useRef(false); React.useEffect(() => { if (position === null || reported.current) return; reported.current = true; + if (wasAnsweredOnMount.current) { + onAnswered(claim.id, position); + return; + } + const timer = setTimeout(() => onAnswered(claim.id, position), ADVANCE_DELAY_MS); return () => clearTimeout(timer); }, [position, claim.id, onAnswered]); From 90ae7481a533c4fcbc271da87f9103787002e3e9 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 17 Sep 2026 11:43:49 -0700 Subject: [PATCH 12/71] feat(debates): rebuild the video element to the Figma redesign (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole player, not just the claim layer, from the "Debates" frame (76391:24940) in Geo — Web. Applies to both hosts, since the explore feed card and the fullscreen debates feed render the same component. The claim cards are the substance of it. They were chyron lines in each debater's own corner; they are now one stack of translucent cards in the player's bottom-left, whoever is speaking. Each carries the speaker's avatar and name, the crowd's share on the claim, and the two ways to answer on its top line, with the claim itself clamped to three lines below. Because the card attributes itself, it no longer has to sit over the speaker's tile to say who is talking — which is what lets the stack have one fixed home instead of jumping between halves mid-sentence. Figma fades the stack with a single alpha gradient over the whole region and slides the cards through it. Reproduced per-card instead: the box would have to stay 168px at every player width and the player is responsive. With the stack capped at two the arithmetic is identical — the newest card sits entirely inside the opaque zone, so the card above it carries the whole ramp. Around it: - The two tiles are flush with one 12px radius on the outside, which is what lets the subtitle straddle the seam. That strip is the one part of the player that is never a face, and the one place the subtitle cannot land on the claim stack — which it used to, since both wanted the band above the debater's name. - Play/pause and mute as two 40px circles top-left. Both come up when the debate is not running and recede to hover-only while it is, except mute while muted: feed debates autoplay silent, so that one has to stay findable. This retires the 64px centred play glyph, which was the same control without a permanent home. - The countdown badge gets the frame's stroked ring and translucent backing rather than a filled grey disc. - The debater's name moves to the bottom-right, regular weight on a scrim that now runs to black rather than medium weight with a text shadow. Two things the redesign displaces, confirmed with Preston rather than guessed: the "Winner?" pill and the position chip are gone from the tile. The name row took the corner the pill was in, and winner voting already has two homes — the end-of-debate scorecard and the claims panel. `WinnerVoteButton` stays live for the panel. The card also now shows "65% agree" before the viewer has answered, which reverses the call to withhold it. Worth knowing that it cuts against the usual argument — a viewer who sees the number first is being nudged, and the tally becomes partly a measure of itself — but it is a deliberate choice about what the card is for: a running read of the room rather than a poll. The share takes the claim's own vocabulary, so a factual claim reads "100% verify" and gets chevrons, not thumbs. Verified against the live testnet debate in a real browser at three moments — one card, two stacked, and paused — as well as 484 files / 5683 tests green and a clean typecheck. Co-Authored-By: Claude Opus 5 (1M context) --- .../debates/debates-page-client.test.tsx | 6 +- .../browse/debate-claim-ticker.test.tsx | 78 ++++-- .../debates/browse/debate-claim-ticker.tsx | 213 ++++++++++------ .../debates/browse/debate-feed-player.tsx | 233 +++++++----------- apps/web/core/debates/browse/icons.tsx | 16 +- 5 files changed, 296 insertions(+), 250 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx index 486d26c798..cf449ef064 100644 --- a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx @@ -147,7 +147,11 @@ describe('DebatesPageClient browse feed', () => { expect(screen.getByRole('heading', { name: 'Debates are useful' })).toBeInTheDocument(); expect(screen.getAllByText('Fashion').length).toBeGreaterThan(0); expect(screen.getAllByRole('button', { name: 'Join a debate' }).length).toBeGreaterThan(0); - expect(screen.getAllByText('Winner?').length).toBeGreaterThan(0); + // Both debaters name themselves on their own tile. The "Winner?" pill used to sit here too; + // it moved off the tile entirely when the name row took the bottom-right corner, and winner + // voting now happens on the end-of-debate scorecard and in the claims panel. + expect(screen.getAllByText('Alex').length).toBeGreaterThan(0); + expect(screen.getAllByText('Sam').length).toBeGreaterThan(0); expect(screen.getAllByTestId('entity-vote-buttons')).toHaveLength(2); await waitFor(() => expect(container.querySelectorAll('video')).toHaveLength(2)); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 782267561a..a2f14877f8 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DebateParticipant } from '~/core/debates/api'; import type { TickerWindow } from '~/core/debates/claim-ticker'; import type { TimedClaim } from '~/core/debates/claim-timing'; @@ -13,20 +14,21 @@ const CLAIM_SPACE = '52c7ae149838b6d47ce0f3b2a5974546'; const mocks = vi.hoisted(() => ({ /** What the shared position control reports the viewer currently holds — a side, not a string. */ viewerPosition: null as boolean | null, + /** The crowd's share of positive responses, or null on a claim nobody has answered. */ + percent: null as number | null, + responseKind: 'stance' as 'stance' | 'veracity', respond: vi.fn(), })); -vi.mock('~/core/claims/browse/claim-summary', () => ({ - ClaimSummary: () =>
, -})); +vi.mock('~/design-system/avatar', () => ({ Avatar: () =>
})); vi.mock('~/core/claims/browse/use-claim-response-state', () => ({ useClaimResponseState: () => ({ - responseKind: 'stance', + responseKind: mocks.responseKind, isResponseKindResolved: true, isViewerResponseResolved: true, responseBlockedReason: null, - summary: { isLoading: false }, + summary: { isLoading: false, percent: mocks.percent }, claim: { id: 'claim-1' }, positions: [], readiness: {}, @@ -67,14 +69,30 @@ function window(overrides: Partial = {}): TickerWindow { return { claim: claim(overrides), startMs: 134_600, endMs: 148_140 }; } +const SPEAKER = { + display_name: 'Peter Feldip', + profile_space_id: '4582fbbee28a16589154f7e36f1ee3c5', + avatar_cid: null, + participant_slot: 1, +} as unknown as DebateParticipant; + function renderCard(props: Partial> = {}) { return render( - + ); } beforeEach(() => { mocks.viewerPosition = null; + mocks.percent = null; + mocks.responseKind = 'stance'; mocks.respond.mockClear(); }); @@ -87,12 +105,12 @@ describe('DebateClaimTickerCard', () => { expect(screen.getByText(/Supreme Court is no longer providing/)).toBeInTheDocument(); }); - // It sits in the debater's own corner, above their name, so repeating the name on the card is - // noise in the smallest space on the screen. - it('does not repeat the speaker, who is named directly below it', () => { + // The card carries its own attribution now, which is what lets the stack live in one fixed + // corner instead of over the speaker's tile. Get this wrong and the card misquotes a real person. + it('names the debater who said it', () => { renderCard(); - expect(screen.queryByText(/just said/)).not.toBeInTheDocument(); + expect(screen.getByText('Peter Feldip')).toBeInTheDocument(); }); it('fades with its window rather than holding at full strength', () => { @@ -101,16 +119,42 @@ describe('DebateClaimTickerCard', () => { expect(container.firstElementChild).toHaveStyle({ opacity: '0.4' }); }); - // The line has to stay a line. A split appearing under it mid-playback would resize the card - // while the reader is looking at it, and the numbers have a home on the end-of-debate card. - it('never grows a crowd split, answered or not', () => { - renderCard(); - expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); + // The card above the newest one dissolves into the video; the newest sits at full strength. In + // Figma that is one gradient over the whole stack, reproduced per-card — see `OLDER_CARD_FADE`. + it('only masks a card that has another below it', () => { + const { container } = renderCard({ fading: true }); + expect((container.firstElementChild as HTMLElement).style.maskImage).toContain('linear-gradient'); cleanup(); - mocks.viewerPosition = true; + const newest = renderCard({ fading: false }); + expect((newest.container.firstElementChild as HTMLElement).style.maskImage).toBe(''); + }); + + it('shows the crowd split before the viewer has answered, per the design', () => { + mocks.percent = 65; + renderCard(); - expect(screen.queryByTestId('crowd-split')).not.toBeInTheDocument(); + + expect(screen.getByText('65% agree')).toBeInTheDocument(); + }); + + // "65% agree" on "the SEC sued Coinbase" is the wrong sentence; the share takes the same verb + // the rest of the app uses for the claim's own vocabulary. + it("reads the share with the claim's own vocabulary verb", () => { + mocks.percent = 65; + mocks.responseKind = 'veracity'; + + renderCard(); + + expect(screen.getByText('65% verify')).toBeInTheDocument(); + }); + + // A genuine 0% and "nobody has answered" are different statements, and the great majority of + // claims are the second one. + it('says nothing about the split on a claim nobody has answered', () => { + renderCard(); + + expect(screen.queryByText(/%/)).not.toBeInTheDocument(); }); // Small, recessive, and unlabelled on screen — so the accessible name is the only thing telling diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 09674d4f74..9828217dcc 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import cx from 'classnames'; import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; -import type { Debate, DebateClaim } from '~/core/debates/api'; +import type { Debate, DebateClaim, DebateParticipant } from '~/core/debates/api'; import { type ClaimMarker, type StackedCard, @@ -26,6 +26,7 @@ import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; +import { Avatar } from '~/design-system/avatar'; import { ChevronDown } from '~/design-system/icons/chevron-down'; import { ChevronUp } from '~/design-system/icons/chevron-up'; import { ThumbDown } from '~/design-system/icons/thumb-down'; @@ -33,12 +34,13 @@ import { ThumbUp } from '~/design-system/icons/thumb-up'; export type DebateTicker = { /** - * The cards to draw over each debater right now, oldest first, keyed by participant slot. + * The cards to draw over the video right now, oldest first. * - * Split by slot because a card belongs over the person who said it: the corner it sits in is - * their corner, above their name. + * One stack for the whole player rather than one per debater. The card carries the speaker's + * avatar and name itself now, so it no longer has to sit over their tile to say who is talking — + * which frees it to live in a single fixed corner and read as a feed of what is being said. */ - stacks: Map; + cards: StackedCard[]; /** Every precisely-placed claim, for the scrubber. */ markers: ClaimMarker[]; /** Claims in the order they were said, for the card at the end. */ @@ -52,8 +54,10 @@ export type DebateTicker = { /** Per-claim lookups, hoisted so the card and the end-of-debate stack share one batch. */ rowsByClaimId: Map; entitiesByClaimId: Map; - /** Claim id → the debater who said it, for the card's "Ana just said". */ + /** Claim id → the debater's display label, for the card at the end. */ speakerByClaimId: Map; + /** Claim id → the debater who said it, for the avatar and name the live card wears. */ + participantByClaimId: Map; }; /** @@ -128,51 +132,42 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled // Attribution rides the *block*, not the claim: a claim's own space is the debate's publication // space, which both debaters share. The block's `Authors` relation points at the speaker's // personal space, which is the id the participant list keys on. - const { speakerByClaimId, slotByClaimId } = React.useMemo(() => { - const bySpace = new Map(); + const { speakerByClaimId, participantByClaimId } = React.useMemo(() => { + const bySpace = new Map(); for (const participant of orderedParticipants(debate)) { - bySpace.set(uuidToHex(participant.profile_space_id), { - label: speakerLabel(participant), - slot: participant.participant_slot, - }); + bySpace.set(uuidToHex(participant.profile_space_id), participant); } - const byBlock = new Map(); + const byBlock = new Map(); for (const block of claims.blocks) { const speaker = block.authorSpaceId ? bySpace.get(uuidToHex(block.authorSpaceId)) : undefined; if (speaker) byBlock.set(block.id, speaker); } const labels = new Map(); - const slots = new Map(); + const speakers = new Map(); for (const claim of claims.all) { const speaker = byBlock.get(claim.blockId); if (!speaker) continue; - labels.set(claim.id, speaker.label); - slots.set(claim.id, speaker.slot); + labels.set(claim.id, speakerLabel(speaker)); + speakers.set(claim.id, speaker); } - return { speakerByClaimId: labels, slotByClaimId: slots }; + return { speakerByClaimId: labels, participantByClaimId: speakers }; }, [claims.all, claims.blocks, debate]); - // One stack per debater. A claim whose speaker could not be resolved — attribution and the - // participant list can disagree — is left out rather than parked over whichever tile: putting a - // claim over the wrong face is the misquote this whole layer is careful about. - const stacks = React.useMemo(() => { - const bySlot = new Map(); - if (!enabled) return bySlot; - - for (const card of tickerStack(windows, playheadMs, dismissed)) { - const slot = slotByClaimId.get(card.window.claim.id); - if (slot === undefined) continue; - const existing = bySlot.get(slot); - if (existing) existing.push(card); - else bySlot.set(slot, [card]); - } - return bySlot; - }, [enabled, windows, playheadMs, dismissed, slotByClaimId]); + // One stack for the whole player. A claim whose speaker could not be resolved — attribution and + // the participant list can disagree — is left out rather than drawn anonymously: the card now + // puts a name and a face against the sentence, and putting the wrong one there is the misquote + // this whole layer is careful about. + const cards = React.useMemo(() => { + if (!enabled) return []; + return tickerStack(windows, playheadMs, dismissed).filter(card => + participantByClaimId.has(card.window.claim.id) + ); + }, [enabled, windows, playheadMs, dismissed, participantByClaimId]); return { - stacks, + cards, markers, claims: timedClaims, answered, @@ -182,20 +177,36 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled rowsByClaimId, entitiesByClaimId, speakerByClaimId, + participantByClaimId, }; } +/** + * The ramp the card above the newest one wears as it ages out. + * + * Figma draws this as one 209×168 alpha gradient over the whole stack region — transparent at the + * top, fully opaque 71.5px down — with the cards sliding up through it. Reproduced per-card rather + * than as a mask on a fixed-height box, because that box would have to stay 168px at every player + * width and the player is responsive. With the stack capped at two (`MAX_STACKED_CARDS`) the + * arithmetic comes out the same: the newest card sits entirely inside the opaque zone, so the card + * above it is the only one carrying any of the ramp. + */ +const OLDER_CARD_FADE = 'linear-gradient(to bottom, transparent 6%, #000 89%)'; + /** * The claim card that rises over the video as it is said. * - * Sits in the seam between the two tiles, which is the one strip of the player that is never a - * face. One card at a time, dismissible, and it never pauses the video or opens a dialog — the - * only thing that interrupts playback is the sign-in prompt, which is the app's standard prompt - * and only appears if the viewer presses a pill while signed out. + * A translucent dark card in the player's bottom-left corner, stacked upward: the newest arrives + * at the bottom and earlier ones ride up and dissolve, which is the shape of a chat rather than a + * dialog. It never pauses the video or opens anything — the only thing that interrupts playback is + * the sign-in prompt, which is the app's standard prompt and only appears if the viewer presses a + * thumb while signed out. */ export function DebateClaimTickerCard({ window, opacity = 1, + fading = false, + speaker = null, row, entity, onAnswered, @@ -203,6 +214,10 @@ export function DebateClaimTickerCard({ window: TickerWindow; /** Driven by the playhead, so a scrub lands on the right strength rather than mid-animation. */ opacity?: number; + /** True for a card that has another below it — the one the stack's gradient dissolves. */ + fading?: boolean; + /** Who said it. The card names them, so it no longer has to sit over their tile to attribute. */ + speaker?: DebateParticipant | null; row: DebateClaim | null; entity: Entity | null; onAnswered: (claimId: string, position: boolean) => void; @@ -216,38 +231,42 @@ export function DebateClaimTickerCard({ // The video behind is one big play/pause button; without this every tap on a thumb would // also toggle playback. onClick={event => event.stopPropagation()} - style={{ opacity }} - className="pointer-events-auto flex w-full flex-col gap-0.5 rounded bg-black/65 px-2 py-1.5 backdrop-blur-[2px]" + style={{ + opacity, + ...(fading ? { maskImage: OLDER_CARD_FADE, WebkitMaskImage: OLDER_CARD_FADE } : null), + }} + className="pointer-events-auto flex w-full flex-col gap-1.5 rounded-lg bg-[#151515]/30 p-3" > - {claim.text} - + {/* Three lines and then an ellipsis. A claim that runs long is a claim the viewer can read in + full in the panel; letting the card grow to fit it would cover the face saying it. */} +

{claim.text}

); } /** - * The stack of claim cards over one debater, oldest at the top. + * The stack of claim cards in the player's bottom-left corner, oldest at the top. * - * Anchored above the name in the corner rather than centred over the video: a card in the middle - * reads as a dialog demanding an answer, and it covers the face of the person making the argument. - * Here it behaves like a chat — the newest arrives at the bottom, earlier ones ride up and fade. + * Anchored in the corner rather than centred over the video: a card in the middle reads as a + * dialog demanding an answer, and it covers the face of the person making the argument. */ export function DebateClaimTickerStack({ cards, - maxWidth, + participantByClaimId, rowsByClaimId, entitiesByClaimId, onAnswered, }: { cards: StackedCard[]; - /** The name row's width, so a line never runs past the debater's position chip. */ - maxWidth?: number | null; + participantByClaimId?: Map; rowsByClaimId: Map; entitiesByClaimId: Map; onAnswered: (claimId: string, position: boolean) => void; @@ -255,15 +274,15 @@ export function DebateClaimTickerStack({ if (cards.length === 0) return null; return ( -
- {cards.map(card => ( +
+ {cards.map((card, index) => ( void; @@ -308,6 +332,7 @@ function TickerClaimControls({ claim, positions, readiness, + summary, } = useClaimResponseState({ claimId, spaceId, row, entity }); const control = useClaimPositionControl({ @@ -335,30 +360,54 @@ function TickerClaimControls({ }, [position, claimId, onAnswered]); const copy = ENTITY_RESPONSE_COPY[responseKind]; + // Null on a claim nobody has answered, which is most of them — and a genuine 0% is a different + // statement from "no responses", so the share drops out rather than printing a zero. + const percent = summary.percent; return ( - - control.respond(true)} - /> - control.respond(false)} - /> - {/* No crowd split and no error text here. Both would make the card grow while the reader is - part-way through it; the end-of-debate card is where the numbers live. */} - +
+ + {speaker && ( + + + + )} + {speaker && {speakerLabel(speaker)}} + {percent !== null && ( + <> + {speaker && ·} + {/* Same wording as the verdict on the claim page — "65% agree", or "65% verify" on a + factual claim, so the share reads the same wherever it is printed. */} + + {percent}% {copy.positiveAction.toLowerCase()} + + + )} + + {/* 4px apart rather than the Figma card's 12px: those are bare 12px glyphs and these are + 20px buttons, so the same gap between glyph *centres* needs a smaller gap between boxes. + No error text here — it would make the card grow while the reader is part-way through it. */} + + control.respond(true)} + /> + control.respond(false)} + /> + +
); } diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index decd45d83a..e50ca2253c 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -5,7 +5,6 @@ import * as React from 'react'; import cx from 'classnames'; import type { Debate, DebateParticipant } from '~/core/debates/api'; -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 type { DebateVotesResult } from '~/core/debates/use-debate-votes'; @@ -20,10 +19,8 @@ import { Text } from '~/design-system/text'; import { ClaimScrubberMarkers, DebateClaimTickerStack, useDebateClaimTicker } from './debate-claim-ticker'; import { DebateScorecard } from './debate-scorecard'; -import { Play, Speaker, SpeakerMuted } from './icons'; -import { WinnerVoteButton } from './winner-vote-button'; -import type { ClaimMarker, StackedCard } from '~/core/debates/claim-ticker'; -import type { DebateTicker } from './debate-claim-ticker'; +import { Pause, Play, Speaker, SpeakerMuted } from './icons'; +import type { ClaimMarker } from '~/core/debates/claim-ticker'; type DebateFeedPlayerProps = { debate: Debate; @@ -63,7 +60,6 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb playheadSeconds, timelineSeconds, turnState, - activeSlot, subtitle, onPlaybackTick, togglePlayback: togglePlaybackRaw, @@ -104,49 +100,47 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb const ticker = useDebateClaimTicker(debate, playheadSeconds * 1000, active || preload); const showControls = ready && (userPaused || (playbackEnded && !hasVoted)); - // End of an unvoted debate offers a replay; a user pause shows the paused glyph. - const showReplay = ready && playbackEnded && !hasVoted; - const showPausedGlyph = ready && userPaused && !playbackEnded; + // The two overlay controls sit at full strength whenever the debate is not running, and recede to + // hover-only while it is. Feed debates autoplay muted, so the mute control is the exception: it + // stays up during playback while muted, because otherwise there is no way to find the audio. + const recede = 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'; + const idle = !playing || playbackEnded; return ( -
+ // 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 instead of sitting inside one of them. +
- - - ) : ready ? ( - // Feed debates autoplay muted, so the unmute control stays visible during - // playback — otherwise there's no way to hear audio. Once unmuted it recedes - // to hover-only. - { - measurement.control(mutedByUser ? 'unmute' : 'mute'); - setMutedByUser(current => !current); - }} - className={ - mutedByUser - ? undefined - : 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100' - } - > - {mutedByUser ? : } - + ready ? ( + <> + + {playing ? : playbackEnded ? : } + + { + measurement.control(mutedByUser ? 'unmute' : 'mute'); + setMutedByUser(current => !current); + }} + className={mutedByUser || idle ? undefined : recede} + > + {mutedByUser ? : } + + ) : null } /> @@ -156,14 +150,12 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb videoRef={slot2VideoRef} audible={playing && turnState?.slot === 2} countdown={playing && turnState?.slot === 2 ? turnState : null} - subtitle={activeSlot === 2 ? subtitle : null} - claims={playbackEnded ? [] : (ticker.stacks.get(2) ?? [])} - ticker={ticker} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} onToggle={togglePlayback} - votes={votes} + // Taller than the top tile's, per the frame: this is the half the claim stack sits over. + scrimClassName="h-[4.625rem]" scrubber={ ready ? ( // Always available so the viewer can seek. During playback it recedes to @@ -189,6 +181,33 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } /> + {/* One stack for the whole player, in the bottom-left corner, whoever is speaking. The card + names its own speaker now, so it does not have to be parked over their tile to attribute — + and a fixed corner means a claim does not jump between halves mid-sentence. + + Capped at the 209px the frame draws it at. The explore card is 484px wide, where 43% comes + out at exactly that; the fullscreen player is far wider, and letting the card scale with it + would hold a paragraph and stop being a glance. */} + {!playbackEnded && ticker.cards.length > 0 && ( +
+ +
+ )} + + {/* Straddling the seam between the tiles, which is the one strip of the player that is never + a face — and the one place it cannot land on top of the claim stack. */} + {subtitle && ( + + {subtitle} + + )} + {/* Dimmed behind, so the card reads as the moment the debate arrives at rather than a note stuck over two frozen faces. */} {ready && playbackEnded && ( @@ -197,17 +216,6 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb
)} - {showPausedGlyph && ( - - )} - {error && ( {error} @@ -223,38 +231,29 @@ function DebaterVideo({ videoRef, audible, countdown, - subtitle, - claims, - ticker, mutedByUser, isResuming, onPlaybackTick, onToggle, - votes, topLeft, scrubber, + scrimClassName = 'h-14', }: { participant: DebateParticipant | null; src: string | null; videoRef: React.RefObject; audible: boolean; countdown: TurnState; - subtitle: string | null; - /** The claims this debater is making right now, oldest first. */ - claims: StackedCard[]; - ticker: DebateTicker; mutedByUser: boolean; isResuming: boolean; onPlaybackTick: () => void; onToggle: () => void; - votes: DebateVotesResult; topLeft?: React.ReactNode; scrubber?: React.ReactNode; + scrimClassName?: string; }) { const { openSidePanel } = useEntitySidePanel(); const name = participant ? speakerLabel(participant) : 'Debater'; - const identityRef = React.useRef(null); - const claimWidth = useIdentityRowWidth(identityRef); const muted = !audible || mutedByUser; @@ -308,7 +307,7 @@ function DebaterVideo({ }; return ( -
+
{/* Clicking anywhere on the video toggles pause/play. */} - {/* Bottom gradient scrim for legibility of the overlaid controls. */} -
+ {/* Bottom gradient scrim for legibility of the overlaid controls. All the way to black, per + the frame — the name that sits on it is regular weight and carries no text shadow. */} +
- {topLeft &&
{topLeft}
} + {topLeft &&
{topLeft}
} {countdown && } - {/* Claims and the subtitle share one bottom-anchored column so they cannot land on top of - each other — they did, because both wanted the strip above the debater's name. The - subtitle stays pinned at the bottom of the column and the claims rise above it. */} - {(subtitle || claims.length > 0) && ( -
- {/* Capped at the width of the name row below, so a claim never runs out past the - debater's position chip. Measured rather than guessed: the row is as wide as the - name, and names vary. */} - - {subtitle && ( - - {subtitle} - - )} -
- )} - - {/* Debater identity: avatar + name + position, opens their personal space in the side panel. */} + {/* Debater identity, opens their personal space in the side panel. On the right, because the + left of the bottom band is where the claim stack now lives. */} - {participant && ( - votes.castVote(participant)} - /> - )} - {scrubber &&
{scrubber}
}
); } -/** - * The rendered width of the debater's name row, so the claim lines above it can stop where it - * stops. - * - * There is no CSS way to say "no wider than that sibling" when the sibling is absolutely - * positioned and its width comes from its own content. Returns null until measured, and on any - * renderer without `ResizeObserver`, in which case the lines fall back to their own max width. - */ -function useIdentityRowWidth(ref: React.RefObject): number | null { - const [width, setWidth] = React.useState(null); - - React.useEffect(() => { - const element = ref.current; - if (!element || typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver(entries => { - for (const entry of entries) setWidth(entry.contentRect.width); - }); - observer.observe(element); - return () => observer.disconnect(); - }, [ref]); - - return width; -} - function CountdownBadge({ seconds, progress }: { seconds: number; progress: number }) { // The ring shows time remaining, so it shrinks as the turn elapses rather than filling up. const remaining = 1 - Math.max(0, Math.min(1, progress)); const degrees = remaining * 360; return ( -
- {Math.ceil(seconds)} +
+ + + {Math.ceil(seconds)} +
); } @@ -455,7 +404,7 @@ function ControlCircle({ event.stopPropagation(); onClick(); }} - className={cx('grid size-8 place-items-center rounded-full bg-white text-text shadow-light', className)} + className={cx('grid size-10 place-items-center rounded-full bg-white text-text shadow-light', className)} > {children} diff --git a/apps/web/core/debates/browse/icons.tsx b/apps/web/core/debates/browse/icons.tsx index 80503767fd..27d5b02ab9 100644 --- a/apps/web/core/debates/browse/icons.tsx +++ b/apps/web/core/debates/browse/icons.tsx @@ -75,18 +75,18 @@ export function Share({ color }: Props) { ); } -export function Pause({ color }: Props) { +export function Pause({ color, size = 18 }: Props & { size?: number }) { return ( - + ); } -export function Play({ color }: Props) { +export function Play({ color, size = 18 }: Props & { size?: number }) { return ( - + + + Date: Thu, 17 Sep 2026 13:28:52 -0700 Subject: [PATCH 13/71] feat(debates): persist claim cards, open the backlog on hover, expand long claims (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things Preston asked for after watching it run. **Cards persist.** A card used to be dropped when its window closed, which left the corner empty for most of a debate and gave the viewer nothing to go back to. Now the only thing that moves a card off the resting stack is a newer claim arriving. The fade-out and the linger and visible-duration caps go with it — a card's end no longer means anything, so `TickerWindow` keeps only the moment it starts. Answering no longer dismisses a card either. It used to, which took the side the viewer had just chosen off the screen before they saw it land; the filled icon is the acknowledgement and the card stays. Nothing populates the dismissed set after that, so it and `onDismiss` are gone. **The stack opens into the whole backlog on hover, or on focus.** Everything said so far, scrollable, newest at the bottom and scrolled there, capped to the lower half so it never reaches over the other debater's face. Bounded by the playhead rather than the whole debate: a claim the viewer has not reached is a spoiler, and "scroll back through what was said" is only a statement about what is behind them. No fade in the open list — the resting stack's gradient says "this one is passing", which is the wrong thing to say about a list someone is reading. **A truncated claim expands in place, and closes again.** Only when it is actually truncated; a button that visibly does nothing is worse than none. The measurement already existed for the feed's debate title, so it is now one hook both share, which is where the next two bugs turned up. **Play/pause is always up.** It is the control you reach for without looking, and receding to hover-only meant there was no visible way to stop a video that had already started. Mute still recedes once the viewer has turned the sound on. **The subtitle now centres its glyphs on the crease, not its box.** The box was already exact; Calibre's line box is asymmetric, so the type sat about 2px low — and on a rule the eye is already using, 2px reads. `text-box: trim-both cap alphabetic` trims the line box to the cap-height band, which is what Figma's own frame specifies, and brings the pill to 21.83px against the frame's 22. Browsers without it fall back to the box being centred, which is where this started. Two bugs of my own, both caught in the browser rather than by the tests: - `block` on the claim text was overriding `line-clamp-3`'s own `display`, so the clamp silently did nothing and the card grew to fit the whole claim. Nothing reported as truncated because nothing was. - `useLineClampOverflow` took a `RefObject`, and wrapping the text in a button once it is truncated re-parents the node — React remounts it, the effect keyed on the stable ref object does not re-run, and the `ResizeObserver` stays attached to a detached node. It then fires at zero height and latches the answer back to "fits" for good. The hook takes the element now, so the node is in the dependency list. It also re-measures on the next frame and once webfonts land, because a clamped box's own height never changes and so the observer has nothing to fire on — whatever the first read says would otherwise stand forever. Verified against the live testnet debate: cards still resting at 2:30 from claims said minutes earlier, hover opening 10 of them scrolled to the bottom, a claim expanding from three lines to four, play/pause at full opacity mid-playback, and the subtitle box dead on the seam. 484 files / 5689 tests green, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../browse/debate-claim-ticker.test.tsx | 106 +++++++++++- .../debates/browse/debate-claim-ticker.tsx | 154 +++++++++++++++--- .../debates/browse/debate-feed-player.tsx | 25 ++- apps/web/core/debates/browse/debate-feed.tsx | 35 +--- .../debates/browse/line-clamp-overflow.ts | 77 +++++++++ apps/web/core/debates/claim-ticker.test.ts | 76 +++++---- apps/web/core/debates/claim-ticker.ts | 88 +++++----- 7 files changed, 427 insertions(+), 134 deletions(-) diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index a2f14877f8..bed2f1903c 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -7,7 +7,7 @@ import type { DebateParticipant } from '~/core/debates/api'; import type { TickerWindow } from '~/core/debates/claim-ticker'; import type { TimedClaim } from '~/core/debates/claim-timing'; -import { DebateClaimTickerCard } from './debate-claim-ticker'; +import { DebateClaimTickerCard, DebateClaimTickerStack } from './debate-claim-ticker'; const CLAIM_SPACE = '52c7ae149838b6d47ce0f3b2a5974546'; @@ -66,7 +66,7 @@ function claim(overrides: Partial = {}): TimedClaim { } function window(overrides: Partial = {}): TickerWindow { - return { claim: claim(overrides), startMs: 134_600, endMs: 148_140 }; + return { claim: claim(overrides), startMs: 134_600 }; } const SPEAKER = { @@ -215,3 +215,105 @@ describe('DebateClaimTickerCard', () => { expect(container).toBeEmptyDOMElement(); }); }); + +/** + * jsdom lays nothing out, so a clamped element reports zero for both heights and the overflow + * measurement correctly concludes there is nothing to expand. These make it report a fourth line. + */ +function forceClampedOverflow() { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get: () => 68 }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 51 }); +} + +describe('the claim text', () => { + afterEach(() => { + // @ts-expect-error — dropping the stubs restores jsdom's own zero-height getters. + delete HTMLElement.prototype.scrollHeight; + // @ts-expect-error — as above. + delete HTMLElement.prototype.clientHeight; + }); + + // Most claims fit in three lines, and a control that visibly does nothing is worse than none. + it('is not a control when the whole claim already fits', () => { + renderCard(); + + expect(screen.queryByTitle('Show the whole claim')).not.toBeInTheDocument(); + }); + + it('expands in place when there is more than the clamp shows, and collapses again', () => { + forceClampedOverflow(); + renderCard(); + + const toggle = screen.getByTitle('Show the whole claim'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(toggle); + expect(screen.getByTitle('Show less')).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(screen.getByTitle('Show less')); + expect(screen.getByTitle('Show the whole claim')).toHaveAttribute('aria-expanded', 'false'); + }); + + // The video behind is one large play/pause button, so reading a claim must not stop the debate. + it('does not toggle playback when expanded', () => { + forceClampedOverflow(); + const onToggle = vi.fn(); + + render( + + ); + fireEvent.click(screen.getByTitle('Show the whole claim')); + + expect(onToggle).not.toHaveBeenCalled(); + }); +}); + +describe('DebateClaimTickerStack', () => { + const resting = [{ window: window(), opacity: 1 }]; + const history = [ + { window: window({ id: 'older', text: 'Congress has ceded its war powers over decades' }), opacity: 1 }, + { window: window(), opacity: 1 }, + ]; + + function renderStack() { + return render( + + ); + } + + it('rests on the most recent claims rather than the whole backlog', () => { + renderStack(); + + expect(screen.queryByText(/Congress has ceded/)).not.toBeInTheDocument(); + }); + + // The point of the corner is that the backlog is one hover away — no pause, no panel. + it('opens everything said so far on hover, and closes again on leave', () => { + const { container } = renderStack(); + const stack = container.firstElementChild as HTMLElement; + + fireEvent.mouseEnter(stack); + expect(screen.getByText(/Congress has ceded/)).toBeInTheDocument(); + + fireEvent.mouseLeave(stack); + expect(screen.queryByText(/Congress has ceded/)).not.toBeInTheDocument(); + }); + + // Hover is not available to a keyboard, and the backlog is content rather than decoration. + it('opens on focus reaching the stack', () => { + const { container } = renderStack(); + + fireEvent.focus(container.firstElementChild as HTMLElement); + + expect(screen.getByText(/Congress has ceded/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 9828217dcc..a1f81deaab 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -10,6 +10,7 @@ import { type ClaimMarker, type StackedCard, type TickerWindow, + claimHistory, claimMarkers, tickerStack, tickerWindows, @@ -32,6 +33,8 @@ import { ChevronUp } from '~/design-system/icons/chevron-up'; import { ThumbDown } from '~/design-system/icons/thumb-down'; import { ThumbUp } from '~/design-system/icons/thumb-up'; +import { useLineClampOverflow } from './line-clamp-overflow'; + export type DebateTicker = { /** * The cards to draw over the video right now, oldest first. @@ -41,6 +44,13 @@ export type DebateTicker = { * which frees it to live in a single fixed corner and read as a feed of what is being said. */ cards: StackedCard[]; + /** + * Every claim said so far, oldest first — what the resting stack opens into on hover. + * + * Bounded by the playhead, so it is a record of what has been said rather than a table of + * contents for what is coming. + */ + history: StackedCard[]; /** Every precisely-placed claim, for the scrubber. */ markers: ClaimMarker[]; /** Claims in the order they were said, for the card at the end. */ @@ -50,7 +60,6 @@ export type DebateTicker = { /** Which way they answered each one, for the tally at the end. */ answers: ReadonlyMap; onAnswered: (claimId: string, position: boolean) => void; - onDismiss: (claimId: string) => void; /** Per-claim lookups, hoisted so the card and the end-of-debate stack share one batch. */ rowsByClaimId: Map; entitiesByClaimId: Map; @@ -72,20 +81,18 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled const { timings } = useClaimTimings(debate.id, claims, enabled); const [answers, setAnswers] = React.useState>(() => new Map()); - const [dismissed, setDismissed] = React.useState>(() => new Set()); + // Recorded, not acted on. Answering used to also dismiss the card, which took the side the + // viewer had just chosen off the screen before they saw it land; the filled icon is the + // acknowledgement now, and the card stays until a newer claim displaces it. The end-of-debate + // card still reads this to skip what has already been answered. const onAnswered = React.useCallback((claimId: string, position: boolean) => { setAnswers(current => new Map(current).set(claimId, position)); - setDismissed(current => new Set(current).add(claimId)); }, []); // The ids alone, for every caller that only asks "has this been answered". const answered = React.useMemo(() => new Set(answers.keys()), [answers]); - const onDismiss = React.useCallback((claimId: string) => { - setDismissed(current => new Set(current).add(claimId)); - }, []); - const timedClaims = React.useMemo(() => claimsInSpokenOrder(claims.all, timings), [claims.all, timings]); const windows = React.useMemo(() => tickerWindows(timedClaims), [timedClaims]); @@ -159,21 +166,28 @@ export function useDebateClaimTicker(debate: Debate, playheadMs: number, enabled // the participant list can disagree — is left out rather than drawn anonymously: the card now // puts a name and a face against the sentence, and putting the wrong one there is the misquote // this whole layer is careful about. - const cards = React.useMemo(() => { - if (!enabled) return []; - return tickerStack(windows, playheadMs, dismissed).filter(card => - participantByClaimId.has(card.window.claim.id) - ); - }, [enabled, windows, playheadMs, dismissed, participantByClaimId]); + const attributed = React.useMemo( + () => windows.filter(window => participantByClaimId.has(window.claim.id)), + [windows, participantByClaimId] + ); + + const cards = React.useMemo( + () => (enabled ? tickerStack(attributed, playheadMs) : []), + [enabled, attributed, playheadMs] + ); + const history = React.useMemo( + () => (enabled ? claimHistory(attributed, playheadMs) : []), + [enabled, attributed, playheadMs] + ); return { cards, + history, markers, claims: timedClaims, answered, answers, onAnswered, - onDismiss, rowsByClaimId, entitiesByClaimId, speakerByClaimId, @@ -245,43 +259,139 @@ export function DebateClaimTickerCard({ entity={entity} onAnswered={onAnswered} /> - {/* Three lines and then an ellipsis. A claim that runs long is a claim the viewer can read in - full in the panel; letting the card grow to fit it would cover the face saying it. */} -

{claim.text}

+
); } +/** Three lines of a claim before the card would start covering the face saying it. */ +const TICKER_CLAMP_LINES = 3; + +/** + * The claim itself, clamped to three lines, and expandable in place when there is more. + * + * A claim runs to a sentence or two and three lines of a 209px card is often not all of it. The + * alternative to expanding here is "go and find it in the panel", which means leaving the debate + * to read a sentence that is on screen — so the text opens where it is, and closes again the same + * way. + * + * Only interactive when it is actually truncated. A button that visibly does nothing is worse than + * no button, and most short claims fit. + */ +function TickerClaimText({ text }: { text: string }) { + // In state rather than a ref: wrapping the text in a button below remounts this node, and the + // measurement has to follow it. See `useLineClampOverflow`. + const [textElement, setTextElement] = React.useState(null); + const [expanded, setExpanded] = React.useState(false); + + // A new claim in the same card slot starts collapsed rather than inheriting the last one's state. + React.useEffect(() => setExpanded(false), [text]); + + const overflowing = useLineClampOverflow(textElement, { + maxLines: TICKER_CLAMP_LINES, + enabled: !expanded, + contentKey: text, + }); + + const body = ( + + {text} + + ); + + if (!overflowing && !expanded) return body; + + return ( + + ); +} + /** * The stack of claim cards in the player's bottom-left corner, oldest at the top. * * Anchored in the corner rather than centred over the video: a card in the middle reads as a * dialog demanding an answer, and it covers the face of the person making the argument. + * + * At rest it shows the last couple of claims. Pointing at it — or tabbing into it — opens the + * whole of what has been said so far, scrollable, newest at the bottom. That is the Twitch-chat + * bargain: the corner stays a corner while you are watching, and the backlog is there the moment + * you go looking for it, without pausing the video or opening a panel. */ export function DebateClaimTickerStack({ cards, + history, participantByClaimId, rowsByClaimId, entitiesByClaimId, onAnswered, }: { cards: StackedCard[]; + /** Everything said so far. Falls back to the resting stack where a caller has no history. */ + history?: StackedCard[]; participantByClaimId?: Map; rowsByClaimId: Map; entitiesByClaimId: Map; onAnswered: (claimId: string, position: boolean) => void; }) { - if (cards.length === 0) return null; + const [open, setOpen] = React.useState(false); + const scrollRef = React.useRef(null); + + const shown = open ? (history ?? cards) : cards; + + // Opened at the bottom, on the claim they were just looking at — scrolling *up* from there is + // the "go back through it" this exists for. Re-run as the history grows so a claim arriving + // while the list is open does not leave the view stranded mid-list. + React.useEffect(() => { + if (!open) return; + const element = scrollRef.current; + if (element) element.scrollTop = element.scrollHeight; + }, [open, shown.length]); + + if (shown.length === 0) return null; return ( -
- {cards.map((card, index) => ( +
setOpen(true)} + onMouseLeave={() => setOpen(false)} + onFocus={() => setOpen(true)} + // Only when focus leaves the stack entirely — moving between two cards inside it must not + // collapse the list out from under the keyboard. + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false); + }} + className={cx( + 'pointer-events-auto flex w-full flex-col gap-1.5', + // Capped to the lower half so the open list never reaches over the other debater's face, + // and scrollable inside that. + open && 'no-scrollbar max-h-[calc(50%-1.5rem)] overflow-y-auto' + )} + > + {shown.map((card, index) => ( {playing ? : playbackEnded ? : } @@ -185,13 +185,18 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb names its own speaker now, so it does not have to be parked over their tile to attribute — and a fixed corner means a claim does not jump between halves mid-sentence. + `inset-y-*` rather than a bare `bottom`: the stack opens on hover into everything said so + far, and a percentage max-height inside it needs a containing block with a height to be a + percentage *of*. Anchored to the bottom by `justify-end` instead. + Capped at the 209px the frame draws it at. The explore card is 484px wide, where 43% comes out at exactly that; the fullscreen player is far wider, and letting the card scale with it would hold a paragraph and stop being a glance. */} {!playbackEnded && ticker.cards.length > 0 && ( -
+
+ {subtitle} )} diff --git a/apps/web/core/debates/browse/debate-feed.tsx b/apps/web/core/debates/browse/debate-feed.tsx index ada923b934..30c536fd6f 100644 --- a/apps/web/core/debates/browse/debate-feed.tsx +++ b/apps/web/core/debates/browse/debate-feed.tsx @@ -34,7 +34,7 @@ import { DebateClaimsPanel } from './debate-claims-panel'; import { DebateFeedPlayer } from './debate-feed-player'; import { DebateInteractionBar } from './debate-interaction-bar'; import { DebateScrollHint, scrollHintBounceProps, useDebateScrollHint } from './debate-scroll-hint'; -import { exceedsLineClamp } from './line-clamp-overflow'; +import { useLineClampOverflow } from './line-clamp-overflow'; import { DebateShareDialog } from './share-dialog'; import { useDebateShareAction } from './use-debate-share-action'; import { useDebatesBestOrder } from './use-debates-best-order'; @@ -544,35 +544,16 @@ function DebateTitleHeader({ topics: string[]; onOpenJoin: () => void; }) { - const claimRef = React.useRef(null); + const [claimElement, setClaimElement] = React.useState(null); const [isClaimExpanded, setIsClaimExpanded] = React.useState(false); - const [isClaimOverflowing, setIsClaimOverflowing] = React.useState(false); React.useEffect(() => setIsClaimExpanded(false), [claim]); - React.useLayoutEffect(() => { - const element = claimRef.current; - if (!element || isClaimExpanded) return; - - const measureOverflow = () => - setIsClaimOverflowing( - exceedsLineClamp({ - contentHeight: element.scrollHeight, - clampedHeight: element.clientHeight, - // Read on every measure rather than once: the breakpoint swaps the whole type scale, so a - // rotation or a resize past 767px changes the line height this is counting in. - lineHeight: parseFloat(getComputedStyle(element).lineHeight), - maxLines: CLAIM_CLAMP_LINES, - }) - ); - measureOverflow(); - - if (typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver(measureOverflow); - observer.observe(element); - return () => observer.disconnect(); - }, [claim, isClaimExpanded]); + const isClaimOverflowing = useLineClampOverflow(claimElement, { + maxLines: CLAIM_CLAMP_LINES, + enabled: !isClaimExpanded, + contentKey: claim, + }); return (
@@ -620,7 +601,7 @@ function DebateTitleHeader({

= (maxLines + 1) * lineHeight; } + +/** + * {@link exceedsLineClamp}, measured off a live element and kept current as it resizes. + * + * Two surfaces clamp claim text and offer a control to unclamp it — the feed's debate title and + * the claim cards over the video — and the measurement is the fiddly half: the line height has to + * be re-read on every measure because the breakpoint swaps the type scale, and a `ResizeObserver` + * is the only thing that notices the text rewrapping at a width the component never hears about. + * One copy, because a rule decided twice is a rule that will eventually disagree with itself. + * + * `enabled: false` (the text is currently expanded) deliberately *keeps* the last answer rather + * than reporting `false`. An expanded element does not overflow — that is the point of expanding + * it — and resetting here would take away the control that collapses it again. + */ +export function useLineClampOverflow( + /** + * The clamped element, held in state by the caller (`ref={setElement}`) rather than in a ref. + * + * It has to be the node itself and not a `RefObject`, because the node can be replaced while + * every other input stays identical: a caller that wraps the text in a button once it *is* + * truncated re-parents it, React remounts it, and an effect keyed on the ref object would not + * re-run — leaving the observer below attached to a detached node, which reports zero height and + * latches the answer back to "fits" for good. Passing the element puts it in the dependency list. + */ + element: HTMLElement | null, + { + maxLines, + enabled = true, + /** Changes when the text does, so the measurement is redone for new content. */ + contentKey, + }: { maxLines: number; enabled?: boolean; contentKey?: string } +): boolean { + const [overflowing, setOverflowing] = React.useState(false); + + React.useLayoutEffect(() => { + if (!element || !enabled) return; + + const measure = () => + setOverflowing( + exceedsLineClamp({ + contentHeight: element.scrollHeight, + clampedHeight: element.clientHeight, + lineHeight: parseFloat(getComputedStyle(element).lineHeight), + maxLines, + }) + ); + measure(); + + // Measured again on the next frame, and again once webfonts have landed. + // + // One synchronous read is not enough, and the failure is silent. A clamped box's own height is + // `lines × line-height` whatever it contains, so the observer below has nothing to fire on — + // which means whatever the *first* read says is the answer forever. And the first read is the + // least trustworthy one: measured in a layout effect, `scrollHeight` on a freshly-clamped box + // can still report the clamped height rather than the content's, and the face the text is set + // in may not have arrived. Both make a four-line claim look like it fits, and the control that + // would have expanded it is never offered. Re-reading costs a single frame. + const frame = requestAnimationFrame(measure); + let cancelled = false; + void document.fonts?.ready?.then(() => { + if (!cancelled) measure(); + }); + + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure); + observer?.observe(element); + + return () => { + cancelled = true; + cancelAnimationFrame(frame); + observer?.disconnect(); + }; + }, [element, maxLines, enabled, contentKey]); + + return overflowing; +} diff --git a/apps/web/core/debates/claim-ticker.test.ts b/apps/web/core/debates/claim-ticker.test.ts index 005e191fcb..efd2834ae4 100644 --- a/apps/web/core/debates/claim-ticker.test.ts +++ b/apps/web/core/debates/claim-ticker.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ClaimTiming, TimedClaim } from './claim-timing'; -import { CLAIM_LINGER_MS, cardOpacity, claimMarkers, tickerStack, tickerWindows } from './claim-ticker'; +import { cardOpacity, claimHistory, claimMarkers, tickerStack, tickerWindows } from './claim-ticker'; function timed(id: string, timing: ClaimTiming | null, text = `Claim ${id}`): TimedClaim { return { id, text, spaceId: 'space-1', blockId: 'block-1', publishedTiming: null, timing }; @@ -30,17 +30,11 @@ const wholeTurn = (startMs: number, endMs: number): ClaimTiming => ({ }); describe('tickerWindows', () => { - it('keeps a card up for a beat after the claim finishes, so it can be read and answered', () => { - const [window] = tickerWindows([timed('a', confident(10_000, 14_000))]); + it('puts the claims in the order they are said', () => { + const windows = tickerWindows([timed('b', confident(18_000, 22_000)), timed('a', confident(10_000, 14_000))]); - expect(window.startMs).toBe(10_000); - expect(window.endMs).toBe(14_000 + CLAIM_LINGER_MS); - }); - - it('caps a very long claim rather than leaving a card up over the next one', () => { - const [window] = tickerWindows([timed('a', confident(0, 30_000))]); - - expect(window.endMs).toBe(12_000); + expect(windows.map(window => window.claim.id)).toEqual(['a', 'b']); + expect(windows[0].startMs).toBe(10_000); }); // The live layer asserts "they are saying this now". A claim the matcher placed roughly is fine @@ -63,8 +57,7 @@ describe('tickerStack', () => { timed('second', confident(18_000, 22_000)), ]); - const ids = (playheadMs: number, dismissed?: Set) => - tickerStack(windows, playheadMs, dismissed).map(card => card.window.claim.id); + const ids = (playheadMs: number) => tickerStack(windows, playheadMs).map(card => card.window.claim.id); it('shows nothing before the first claim is said', () => { expect(ids(5_000)).toEqual([]); @@ -76,16 +69,21 @@ describe('tickerStack', () => { // The newest is last, so rendered down a column it sits at the bottom, nearest the name, with // the older one riding up above it. - it('stacks an overlapping claim under the one before it, newest last', () => { + it('stacks a newer claim under the one before it, newest last', () => { expect(ids(18_500)).toEqual(['first', 'second']); }); - it('drops a claim once its window closes', () => { - expect(ids(14_000 + CLAIM_LINGER_MS)).toEqual(['second']); + // The behaviour this whole change exists for. A card used to be dropped once its window closed, + // which left the corner empty for most of a debate; now it rests there until something newer + // takes its place, and the viewer always has the last thing said in front of them. + it('keeps a claim on screen long after it was said', () => { + expect(ids(17_000)).toEqual(['first']); + expect(ids(200_000)).toEqual(['first', 'second']); }); - // Five claims inside ten seconds is an ordinary turn; the corner has to stay a corner. - it('keeps only the most recent few when a turn is busy', () => { + // Five claims inside ten seconds is an ordinary turn; the corner has to stay a corner. What is + // pushed off is not lost — `claimHistory` still has it. + it('rests on only the most recent few when a turn is busy', () => { const busy = tickerWindows([ timed('a', confident(1_000, 2_000)), timed('b', confident(1_500, 2_500)), @@ -93,11 +91,32 @@ describe('tickerStack', () => { timed('d', confident(2_500, 3_500)), ]); - expect(tickerStack(busy, 3_000, new Set(), 3).map(card => card.window.claim.id)).toEqual(['b', 'c', 'd']); + expect(tickerStack(busy, 3_000, 3).map(card => card.window.claim.id)).toEqual(['b', 'c', 'd']); }); +}); - it('does not re-ask a claim the viewer has answered or dismissed', () => { - expect(ids(12_000, new Set(['first']))).toEqual([]); +describe('claimHistory', () => { + const windows = tickerWindows([ + timed('a', confident(10_000, 14_000)), + timed('b', confident(18_000, 22_000)), + timed('c', confident(30_000, 34_000)), + ]); + + it('carries everything said so far, oldest first', () => { + expect(claimHistory(windows, 20_000).map(card => card.window.claim.id)).toEqual(['a', 'b']); + }); + + // A claim the viewer has not reached yet is a spoiler, and "what was said" is a statement about + // what is behind them. + it('stops at the playhead rather than listing the whole debate', () => { + expect(claimHistory(windows, 12_000).map(card => card.window.claim.id)).toEqual(['a']); + expect(claimHistory(windows, 0).map(card => card.window.claim.id)).toEqual([]); + }); + + // The resting stack's gradient says "this one is passing", which is the wrong thing to say + // about a list someone has deliberately opened to read. + it('holds every entry at full strength', () => { + expect(claimHistory(windows, 200_000).map(card => card.opacity)).toEqual([1, 1, 1]); }); }); @@ -110,21 +129,18 @@ describe('cardOpacity', () => { expect(cardOpacity(window, 10_400)).toBe(1); }); - it('holds at full strength through the middle', () => { + // No fade out any more: a card leaves the resting stack because something newer arrived, not + // because it timed out, and the stack's own gradient is what dissolves it on the way. + it('holds at full strength indefinitely once it has arrived', () => { expect(cardOpacity(window, 12_000)).toBe(1); - }); - - it('fades out over the tail of its window', () => { - expect(cardOpacity(window, window.endMs - 750)).toBeCloseTo(0.5); - expect(cardOpacity(window, window.endMs - 1)).toBeLessThan(0.01); + expect(cardOpacity(window, 999_000)).toBe(1); }); // A scrub lands wherever it lands; the card has to be as visible as its moment says, not as // visible as an animation that started when it mounted. - it('is driven by the playhead, so a scrub into the middle lands at full strength', () => { - expect(cardOpacity(window, 13_000)).toBe(1); + it('is driven by the playhead, so a scrub back before the claim hides it again', () => { expect(cardOpacity(window, 9_000)).toBe(0); - expect(cardOpacity(window, 99_000)).toBe(0); + expect(cardOpacity(window, 13_000)).toBe(1); }); }); diff --git a/apps/web/core/debates/claim-ticker.ts b/apps/web/core/debates/claim-ticker.ts index 76de6d5f36..8ad8a9e260 100644 --- a/apps/web/core/debates/claim-ticker.ts +++ b/apps/web/core/debates/claim-ticker.ts @@ -1,21 +1,10 @@ import { LIVE_TIMING_CONFIDENCE, type TimedClaim } from './claim-timing'; -/** - * How long a claim card stays up after the debater finishes saying it. - * - * Long enough to read a sentence and press a pill, short enough that the next claim is not queuing - * behind it. Turns in the default format run 30s and produce two or three claims, so a linger much - * longer than this starts stacking. - */ -export const CLAIM_LINGER_MS = 5_000; - -/** A card is never up for longer than this, however long the claim itself ran. */ -const MAX_VISIBLE_MS = 12_000; - -export type TickerWindow = { claim: TimedClaim; startMs: number; endMs: number }; +/** A claim eligible to surface over the video, with the moment it starts being said. */ +export type TickerWindow = { claim: TimedClaim; startMs: number }; /** - * The claims eligible to surface over the video, with the window each is visible for. + * The claims eligible to surface over the video, in the order they are said. * * Only confidently-placed claims qualify. A claim the matcher put in roughly the right region is * fine in a list and not fine over the video, where the card asserts "they are saying this now" — @@ -24,27 +13,21 @@ export type TickerWindow = { claim: TimedClaim; startMs: number; endMs: number } export function tickerWindows(claims: TimedClaim[]): TickerWindow[] { return claims .filter(claim => claim.timing !== null && claim.timing.confidence >= LIVE_TIMING_CONFIDENCE) - .map(claim => { - const timing = claim.timing as NonNullable; - const endMs = Math.min(timing.endMs + CLAIM_LINGER_MS, timing.startMs + MAX_VISIBLE_MS); - return { claim, startMs: timing.startMs, endMs }; - }) + .map(claim => ({ claim, startMs: (claim.timing as NonNullable).startMs })) .sort((a, b) => a.startMs - b.startMs); } /** - * How many cards may be stacked over a debater at once, before the oldest is dropped. + * How many cards the resting stack shows, before the older ones are left to the history. * * Two, not three: the stack grows upward from just above the debater's name, and the subtitle sits - * a little higher on the same tile. Three cards reach into it, which is the crowding this layout - * exists to avoid. + * on the seam above it. Three cards reach into it, which is the crowding this layout exists to + * avoid. Nothing is lost to the cap — everything said so far is one hover away. */ export const MAX_STACKED_CARDS = 2; /** Long enough to register as arriving rather than blinking into place. */ const FADE_IN_MS = 250; -/** The tail of a card's window, over which it fades out instead of vanishing. */ -const FADE_OUT_MS = 1_500; /** * How visible a card is at this moment, in [0, 1]. @@ -52,47 +35,60 @@ const FADE_OUT_MS = 1_500; * Driven by the playhead rather than a CSS animation, because the playhead is the source of truth * and it can jump: a viewer who scrubs back into the middle of a claim should find the card at full * strength, not mid-way through an animation that started when the element mounted. + * + * There is no fade *out*, because a card no longer expires. It stays until a newer claim pushes it + * up the stack, where the stack's own gradient dissolves it, and then off the resting stack + * altogether — still in the history. */ export function cardOpacity(window: TickerWindow, playheadMs: number): number { - if (playheadMs < window.startMs || playheadMs >= window.endMs) return 0; - const sinceStart = playheadMs - window.startMs; + if (sinceStart < 0) return 0; if (sinceStart < FADE_IN_MS) return sinceStart / FADE_IN_MS; - - const untilEnd = window.endMs - playheadMs; - if (untilEnd < FADE_OUT_MS) return untilEnd / FADE_OUT_MS; - return 1; } export type StackedCard = { window: TickerWindow; opacity: number }; /** - * The claims to show over a debater right now, oldest first. + * The claims resting over the video right now, oldest first. * * Rendered in this order down a column, the newest card sits at the bottom, nearest the debater's - * name, and earlier ones ride up above it as they age out — the shape of a live chat rather than a - * dialog. Capped at {@link MAX_STACKED_CARDS}: a busy turn can put five claims inside ten seconds, - * and the point of the corner is that it leaves the face alone. + * name, and earlier ones ride up above it — the shape of a live chat rather than a dialog. * - * `dismissed` carries the claims the viewer has answered or waved away. They are skipped rather - * than redrawn, so seeking backwards does not re-ask a question already answered. + * A card persists once its claim has been said. Cards used to be dropped when their window closed, + * which left the corner empty for most of a debate and gave the viewer nothing to go back to; and + * an answered claim was dropped immediately, so the side you had just taken vanished before you + * could see it register. Now the only thing that moves a card off the resting stack is a newer + * claim arriving, and the side you took stays on it. */ export function tickerStack( windows: TickerWindow[], playheadMs: number, - dismissed: ReadonlySet = new Set(), max: number = MAX_STACKED_CARDS ): StackedCard[] { - return windows - .filter( - window => - !dismissed.has(window.claim.id) && playheadMs >= window.startMs && playheadMs < window.endMs - ) - .sort((a, b) => a.startMs - b.startMs) - // Drop the oldest when there are more than fit, so what is on screen is what was just said. - .slice(-max) - .map(window => ({ window, opacity: cardOpacity(window, playheadMs) })); + return ( + spokenSoFar(windows, playheadMs) + // Keep the most recent few: what rests over the video is what was said most recently. + .slice(-max) + .map(window => ({ window, opacity: cardOpacity(window, playheadMs) })) + ); +} + +/** + * Every claim said so far, oldest first — the list the resting stack opens into on hover. + * + * Bounded by the playhead and not by the whole debate. A claim the viewer has not reached yet is a + * spoiler, and "scroll back through what was said" is only a statement about what is behind them. + * All at full strength: the resting stack's gradient says "this one is passing", which is the wrong + * thing to say about a list someone is deliberately reading. + */ +export function claimHistory(windows: TickerWindow[], playheadMs: number): StackedCard[] { + return spokenSoFar(windows, playheadMs).map(window => ({ window, opacity: 1 })); +} + +/** `windows` arrives sorted by {@link tickerWindows}, so this stays in spoken order. */ +function spokenSoFar(windows: TickerWindow[], playheadMs: number): TickerWindow[] { + return windows.filter(window => playheadMs >= window.startMs); } export type ClaimMarker = { id: string; text: string; atMs: number; fraction: number }; From d1b5146f7ccb0783b3577b8021fcd036916b8229 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 17 Sep 2026 15:12:35 -0700 Subject: [PATCH 14/71] fix(debates): lift the claim stack clear of the scrubber while it is showing (GEO-2909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrubber occupies the bottom 20px of the player and the cards sat at 12px, so whenever the bar came up it was drawn straight through the newest card. Measured: 8px of overlap. The stack now pads its bottom by the bar's own height while the bar is on screen, which puts the newest card 12px above it — the same gap it keeps from the bottom edge when the bar is hidden. Eased, because the bar it is making room for fades in rather than appearing. Hover stays in CSS (`group-hover` on both elements), since neither can ask about the other in a class. Keyboard focus could not: a sibling cannot see focus land inside the scrubber, so the scrubber's `focus-within:` variants become one piece of state that both read, and tabbing to the timeline now raises the cards as hovering does. Guarded on `relatedTarget` so moving between the markers and the range input does not blink it off and on. Verified in the browser at all three states — pointer away, hovering, and timeline focused with the pointer parked in the corner: card bottom goes 12px → 22px → 32px above the player's edge against a bar whose top is fixed at 20px, so the 8px overlap becomes clearance in both showing states. The debater's name row still sits at 12px and the bar still passes under it. Left alone deliberately — it is text and an avatar rather than a filled card, and it was not what looked wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../debates/browse/debate-feed-player.tsx | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 095640e74b..108a671191 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -107,6 +107,17 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb const recede = 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'; const idle = !playing || playbackEnded; + // Whether the scrubber is on screen, which the claim stack has to know as well as the scrubber + // itself — it sits in the same bottom band and lifts clear of it. Hover is the remaining case and + // stays in CSS (`group-hover` on both), since neither element can ask about the other in a class. + // + // Keyboard focus is here rather than a `focus-within:` variant for exactly that reason: tabbing + // to the timeline has to raise the cards too, and a sibling cannot see focus land inside the + // scrubber. Guarded on `relatedTarget` so moving between the markers and the range input — both + // inside the wrapper — does not blink it off and on. + const [timelineFocused, setTimelineFocused] = React.useState(false); + const scrubberShown = showControls || timelineFocused; + return ( // 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 instead of sitting inside one of them. @@ -160,12 +171,18 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb ready ? ( // Always available so the viewer can seek. During playback it recedes to // hover-only and drops pointer-events so it can't swallow play/pause taps. + // `pointer-events-none` does not stop keyboard focus reaching the range input, which + // is what brings it back up for a viewer who never touches the pointer.
setTimelineFocused(true)} + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setTimelineFocused(false); + }} className={cx( 'transition-opacity', - showControls + scrubberShown ? 'opacity-100' - : 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100' + : 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100' )} >