Skip to content

feat(debates): ask for positions on every claim once the debate ends (GEO-2909) - #2459

Draft
jwalkingjew wants to merge 30 commits into
masterfrom
preston/geo-2909-end-of-debate-scorecard
Draft

jwalkingjew wants to merge 30 commits into
masterfrom
preston/geo-2909-end-of-debate-scorecard

Conversation

@jwalkingjew

Copy link
Copy Markdown
Collaborator

Stacked on #2439 — review that one first; this targets its branch, so the diff here is only the end card.

Split out on Preston's call so the two can be judged apart: #2439 is the timing work and the live claim layer, this is the card that asks for positions after the video.

What this adds

Once the video has stopped and there is nothing left to interrupt, the player dims and offers the claims one at a time — the same pills the explore feed, the hub and the claims panel use, a Skip beside the speaker and timecode, and Watch again in the header.

A taken side stays on screen for a beat before the card advances. Advancing the instant the response landed swapped the claim out from under the press, so the pill never showed as taken and there was no way to tell the tap had registered. The response publishes either way; only the card waits.

It ends on the debate's own question. Having gone through every claim the viewer has effectively assembled their answer to "who won", so the card tallies which debater they agreed with more often and offers the winner vote right there — per debater, on a row carrying their avatar and name, so which button is whose is obvious. Same hook as the tiles, so the two cannot disagree.

One bug worth the look

The tally is rate-based, not count-based. Agreeing with 7 of 7 and 6 of 6 is agreeing with both; ranking by raw count called that a lean towards whoever happened to make one more claim, which only reports who talked more. debate-scorecard.test.ts pins that case, along with the shared-rejection, even-split, one-debater-only and nothing-answered readings.

It also counts against the claims the viewer actually answered rather than everything a debater said — "5 of 7" out of seven answered is a lean; the same five against a dozen skipped is not.

What it restores

Three DebateTicker fields that only this reads: claims in spoken order, answers, and speakerByClaimId. onAnswered is not among them — the live cards report a taken side through it too, so it stays in #2439.

Recombining

This commit is a straight revert of the removal in #2439, so putting the two back together is one git revert if that turns out to be easier than reviewing them apart.

Testing

Full apps/web suite green on this branch: 484 files, 5714 tests (483 / 5708 on #2439 without it). tsc --noEmit clean apart from the four files already failing on master.

jwalkingjew and others added 30 commits September 15, 2026 12:48
…d 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.
…ills (GEO-2909)

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.
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.
…ding 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.
…the end (GEO-2909)

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.
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.
…ls on the end card (GEO-2909)

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.
…on (GEO-2909)

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.
…R (GEO-2909)

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.
Stacked on GEO-2909, which landed the half this depends on: knowing when each claim
was said, and the card that asks for positions once the video has stopped. This is
the contested half — claims appearing while the debate is still playing.

Each claim is a line of white text on a soft scrim, in the same visual language as
the subtitle, rising in the speaker's own corner and fading with its own window.
Two at a time at most, capped to the width of the debater's name row so a line
never runs past their position chip. Interaction is two small icons under the text,
recessive until hovered: thumbs for a stance claim, chevrons for a factual one.

Only claims the resolver placed confidently appear here. A line asserts "they are
saying this now", and being wrong about that misquotes a real person, so anything
looser stays in the panel and on the end card.

Opacity is computed from the playhead rather than a CSS animation, so scrubbing
into the middle of a claim finds the line at full strength rather than part-way
through an animation that began when it mounted.
… answers (GEO-2909)

Three fixes.

Rewatching a debate you had already answered walked the whole card: each claim
mounted, found a response already published, held for the advance beat and moved
on, so thirteen answered claims flicked past one at a time before the summary
appeared. The rows the hook already batches carry the viewer's side, so those
claims are now filtered out before the queue is built. For the spaces geo-chat does
not index, where the answer only turns up once the card asks, it advances without
the hold — the hold exists so a fresh press is seen landing, and there is nothing
to see on an answer given weeks ago.

The lean was ranked by raw count, so agreeing with 7 of 7 and 6 of 6 read as
"you agreed with Preston most" — which only reports who made one more claim. It now
ranks by rate and names the shared cases: agreed with both, disagreed with both,
split evenly.

And the summary is a scorecard rather than two lines of text. One block per
debater: avatar, name, how many of their claims landed, and a two-segment bar of
agreed against disagreed. Their winner vote lives in their own block, carrying
their avatar and name — as two bare pills in a row there was nothing tying a
percentage to a person, because the shared pill drops the name once voted. Correct
on a tile, where it sits on that debater's own face; unreadable in a card holding
both of them. Behaviour still comes from the shared vote hook, so a vote cast here
shows on the tiles too.
…so-they-can-be' into geo-2909-live-claims-during-playback
…909)

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) <noreply@anthropic.com>
… long claims (GEO-2909)

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) <noreply@anthropic.com>
…showing (GEO-2909)

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) <noreply@anthropic.com>
…the tile (GEO-2909)

The older card's ramp was a fraction of its own height, and a fraction
is the wrong unit: how far the stack reaches up the video is a physical
distance on screen. Figma's older card is two lines where a real claim
is usually three, so the same percentages stretched the ramp over a
taller card and left all of it legible — the stack then climbed most of
the tile while resting.

Pinned in rem and measured up from the card's own bottom instead: its
bottom line stays crisp and everything above it is gone within about two
more, so a long claim fades sooner rather than reaching further.

Also parameterises `verify-claim-timing.ts` by debate id and has it
report the source and confidence of every claim, plus how many clear
each of the two gates. It was pinned to the one debate with published
offsets, which is the single case where the interesting path — the
transcript matcher — never runs. On a debate with none:

  claims: 9   with published timecodes: 0
  by source: block=4  segment=5
  shown as "Said at" (source != block): 5
  ticker-eligible claims: 3 of 9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lign the timecode gate (GEO-2909)

Two things, both about the panel telling the truth about time.

**Ordering.** The panel sorted by ranking score, which is the right
answer for a feed of unrelated claims and the wrong one inside a
transcript: a debate is an argument, and reading its claims out of
sequence loses the thread. It now orders by the moment each claim was
said, which also finally retires the "transcript order" that never was —
relation `position` is `Position.generate()`, random rather than
monotonic, so the old order was a shuffle on every debate.

Where nothing distinguishes when, the ranking still decides. That is
every claim of a single turn (a turn is one moment as far as the
resolver is concerned) and every claim on a debate with no timecodes at
all, so the ranking is doing real work rather than sitting behind a
never-taken branch — `sortClaimsByBest` gains a `compareByBest` sibling
to serve as the tiebreak. The panel now also waits on the timings as
well as the ranking: without them every claim ties, so the list would
paint in ranking order and then rearrange itself under the reader.

Ordering deliberately uses any *usable* match, not only the firm ones.
A list has to put a claim somewhere and where it lands asserts far less
than a timestamp does; a match too loose even for that was already
discarded by the resolver in favour of the claim's own turn.

**The gate.** Preston spotted claims with no published offsets showing
"Said at 2:29" and popping up over the video, and the second half of
that was the bug: the two surfaces disagreed. The live layer asked for
`LIVE_TIMING_CONFIDENCE`; the panel's timecode asked only that the claim
had been matched at all. So a 0.40 match — judged too loose to draw over
the video — still printed a time to the second, indistinguishable from a
published offset. The weaker evidence was making the more precise claim.

Both now go through one `isAssertableMoment`, so there is one bar and it
cannot drift. On a real debate with no published offsets that splits 9
claims three ways:

  by source: block=4  segment=5
  firm enough to state — timecode, live card: 3 of 9
  used for ordering only (matched, below the bar): 2

Scrubber markers still use the looser bar. A marker carries no time and
landing a second early costs the viewer nothing, but it is the remaining
inconsistency if we want all three aligned.

`verify-claim-timing.ts` reports the split above, and takes a debate id
so it can be pointed at a debate the matcher actually runs on.

Backend follow-up filed as GEO-2958.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…very debate (GEO-2909)

`2026-09-15-…-publish-instructions.md` published offsets for one debate,
with 4 of its 13 hand-corrected against the transcript. Preston asked
for the same thing across everything with claims extracted. There is no
hand-correcting 610 of them, so this generates the plan from the matcher
and publishes only what the matcher is confident about.

`scripts/plan-claim-timecodes.ts` enumerates every Debate-typed entity,
resolves each one's claims through the real `resolveClaimTimings`, and
writes the ops out. Read-only. Against testnet right now:

  844 claims across 65 debates that have any
   13 already carry offsets (the test debate — skipped, so reruns are safe)
  221 no usable match at all (whole-turn fallback)
  610 matched, of which 135 clear the floor

The floor is 0.7, above the 0.55 the live layer draws at, and the
reasoning is the point of the doc. `published` is the resolver's *most*
trusted source, scored 1.0 — so writing a weak match does not preserve
its weakness, it launders a guess into an exact answer no later gate can
catch, on a surface that quotes a real person saying something at a
specific second. Leaving a claim alone costs nothing, because the
matcher still runs for it at read time at its true confidence. The
script prints what every floor from 0.35 to 0.8 would publish so the
choice is made with the consequence in view.

Worth being plain that at this floor the backfill changes nothing a
viewer sees: every claim it publishes already displays on the strength
of its match. What it buys is durability (no dependence on geo-chat
still serving that transcript, unchanged), no re-matching per view, and
a record — the committed plan — of which offsets were inferred and how
strongly, which is what a later GEO-2958 backfill will need in order to
know which ones to overwrite with the extractor's real spans.

Found while checking the plan: two claims scored **1.05 and 1.03** from
a `scoreWindow` documented as returning [0, 1]. A repeated claim word
was counted once per occurrence against a *set* of window words, so
`hits` could exceed the window's distinct-word count and push the
precision term — a ratio of one set's size to another's — above 1. A
claim saying "AI" three times outranked a tighter match saying it once.
Both sides are deduped now, which is what containment meant all along;
it moves 9 claims across the floor and caps the scale at 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…floor (GEO-2909)

Preston's call. 309 writes across 62 debates instead of 135 across 49.

0.55 is exactly the bar the live layer draws at, so the rule becomes: if
the app is already willing to say this claim was said at this second,
make it permanent. Nothing is published that a viewer is not already
being shown.

The doc records the consequence rather than hiding it — the floor leaves
no margin, so a claim scoring 0.551 is published at the same 1.0 as a
hand-checked offset, and the boundary cases are exactly the ones a
margin would have caught. The plan file is the mitigation: it carries
every claim's real score, so anything published near the line can be
found again and overwritten when GEO-2958 produces the extractor's true
spans.

Also raises the proposal-size warning, which matters more at this floor:
the largest space is now 448 ops against 52 in the single-debate
publish. Publish the smallest space first, and split by debate if that
is too big — the writes are independent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Back to the recommended floor after a look at both. 140 writes across
50 debates instead of 309 across 62.

0.55 is exactly the bar the live layer draws at, which leaves no margin:
a claim scraping over at 0.551 is written as `published` and scored 1.0,
and the boundary cases are precisely the ones a margin catches. 0.7 buys
that margin for the cost of 169 fewer claims published — all of which
keep working through the read-time matcher at their true confidence,
which is the reason the cost is low.

Side effect worth having: the largest proposal drops from 448 ops to
224, which is a far less alarming first publish of a pattern that has
so far only landed at 52.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hover (GEO-2909)

Restores the behaviour removed when cards were made permanent. A card
appears as its claim is said, lingers five seconds, fades and goes, so
the corner is empty most of a debate and the video gets its bottom-left
back.

What made cards permanent was wanting something to go back to. The
backlog answers that better, and without costing the video a
permanently occupied corner: hovering anywhere over the player opens the
corner into everything said so far, scrollable, newest at the bottom and
scrolled there.

Hover is tracked on the player rather than on the stack, which is the
whole reason this works. A card is on screen for a few seconds at a
time, so a hover target made of the cards is a target that is usually
not there — and "hover to see what was said" has to work in the
silences, which is most of a debate. `open` is now the player's to
decide and the stack takes it as a prop; the stack still reports its own
focus up, so tabbing into it opens the backlog the way the pointer does.

Answering still does not retire a card early. That was removed for its
own reason — it took the side the viewer had just chosen off the screen
before they saw it land — and the window runs out on its own soon
enough.

Verified against the live debate: at 60s the corner is empty, hovering
the *top* of the video opens it to the 4 claims said so far scrolled to
the bottom, and moving the pointer away empties it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tile (GEO-2909)

Preston's idea, and a better argument than the one that merged them: a
viewer watching a debate is looking at whoever is talking, so that is
where their claims should appear. One shared corner asked the eye to
leave the speaker in order to read what the speaker was saying.

Each tile now hosts its own corner in its own bottom-left, and the two
behave exactly as the single one did — live cards that expire, opening
into that debater's backlog on hover or focus. They are independent:
pointing at one debater opens their claims and leaves the other's alone,
which is the whole point of splitting them.

The ticker keys its cards and history by participant slot instead of
returning one list. Attribution already rode the transcript block's
`Authors` relation, so this is the same resolution the merged version
used to decide what to *show*, now deciding where.

Hover moves from the player to the tile for the same reason it was on
the player: the target has to be there when the corner is empty, which
is most of a debate. A tile is that target, per debater.

Only the lower tile hosts the scrubber, so only its corner lifts clear
of the bar — `claimsClearScrubber` says which, rather than both tiles
paying for one tile's chrome.

Verified against the live debate: at 0:20 Preston's claim is on
Preston's tile with Scot's empty, at 3:20 Scot's is on Scot's with
Preston's empty, and hovering one tile opens that debater's backlog
(7 and 5 claims respectively) without touching the other's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…of cutting it off (GEO-2909)

The open list was clipped square at the top of its box — half a card
with a hard line through it, which reads as broken rather than as
more-above. The live stack already dissolves its older card; the backlog
now does the same at its edge.

Applied to the scroll box rather than to a card, so it stays at the edge
while the list moves under it and each card fades on the way out.

Only while something is actually scrolled above it. A short backlog sits
wholly inside the box with nothing cut off, and fading its first card
then would be dimming the top of a list for no reason — so the mask
follows `scrollTop`, set on scroll and after the open-at-the-bottom jump.

Verified all three states: a 7-claim backlog scrolled to the bottom is
masked, the same list scrolled to the very top is not, and a 2-claim
backlog that fits is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ble without hover (GEO-2909)

Preston spotted that a touch screen has no hover, and the backlog was
hover-only. The gap is wider than mobile: the affordance was invisible
on desktop too, so a viewer only found it by accident.

One small pill in the corner fixes both. When nothing is live — most of
a debate — the corner rests on a chip saying how many claims are behind
the playhead. Pressing it opens the backlog in place; pressing Hide
closes it. Hover still opens it on a pointer, unchanged.

It carries the same glyph as the Claims button in the interaction bar,
because it opens the same claims. Two icons for one idea is the harder
thing to learn.

**Tap has to be the only way in on touch, and that took work.** Touch
browsers synthesise `pointerenter` from a tap, so the tile's hover
handler would have fired on every tap on the video — including the tap
that pauses it — throwing the corner open with no corresponding leave to
close it again. The handlers now ignore anything but `pointerType:
'mouse'`. A mouse leaving the tile also clears the pin, so clicking the
chip and moving away does not leave the corner stuck open.

Why not lean on the existing Claims panel for touch: the breakpoints
here are desktop-first, and the panel is `md:w-full` — on mobile it
takes over the screen. Reaching the claims would mean losing the video,
which is the thing this whole layer exists to avoid.

Verified in both: a desktop mouse rests on "4 claims", opens on hover,
closes on leave; an iPhone 13 context taps the video without opening the
corner, then taps the chip to open and Hide to close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review pass. Four findings, all reuse or DRY, all fixed. Net -224
lines and no behaviour change — the suite is unchanged at 5711 green.

**One query, not three.** `verify-claim-timing.ts` and
`plan-claim-timecodes.ts` each carried a copy of the app's forty-line
claims traversal, differing from `debate-transcript-claims-document.ts`
by a name and one field. Both now read the app's document through a
shared `scripts/lib/debate-claims.ts`, so a field added for the UI
cannot silently stop being read by the tooling that checks it. The
duplicated fetch helper and the duplicated hex→uuid regex went with it.
The scripts drop 141 and 94 lines.

**The planner re-walked a traversal the app already groups.** It looped
`transcripts → blocks → claims` by hand purely to reach the relation
entity id, because `TranscriptClaim` did not carry it. It does now —
from the same relation as `blockId`, so the two always describe the same
statement — and the planner just reads `claim.relationEntityId`. The
document selects `entityId` and `presentRelations` carries it, which is
where the rest of that relation's data already flows.

**The response-control wiring was repeated five times, two of them
mine.** Every claim surface pairs `useClaimResponseState` with
`usePrivySignIn` and `useClaimPositionControl`, and each spells out that
`answersReady` is `isResponseKindResolved && isViewerResponseResolved` —
a conjunction whose whole job is to stop a click publishing the wrong
vote kind. `useClaimResponseState` exists because the layer *below* this
drifted the same way; this is the same bug one level up. Now
`useDebateClaimResponse`, used by the panel, the scorecard and the
ticker. It lives in `debates/browse` rather than beside
`useClaimResponseState` because `matchmaking-claim-card` imports from
`claims/browse`, so a hook there importing back would close a cycle. The
claim page and the explore card still wire their own — the claim page is
handed its state rather than resolving it, so it cannot use this as
written.

**Dead configuration.** `ClaimIconButton` was exported with `surface`
and `size` props nothing ever passed — left over from when the scorecard
was going to use it before it moved to the real `PositionRow`. Both
branches and the export are gone. `USABLE_TIMING_CONFIDENCE` and
`DEBATE_TRANSCRIPT_CLAIMS_SOURCE` were exported with no caller; both are
module-private again.

Checked and deliberately left alone: `formatTimecode` has no existing
equivalent in the repo, and the symbols exported only to their own unit
tests stay exported, which is the pattern here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts (GEO-2909)

Anchored to the claim's start, the card arrived while the debater was
still mid-sentence — asserting a claim the viewer had not heard them
make yet. It read as the app putting words in their mouth, and asked the
reader to agree with something still being argued. Anchored to the end
it reads as "he just said this — do you agree?", which is the question
this layer exists to ask.

One line in `tickerWindows`, but two things follow from it:

The cap goes. A window running from the claim's start grew with the
claim and had to be clamped so a long one did not leave its card up over
the next; measured from the end, every window is the same length
whatever the claim did.

The linger goes 5s → 8s. It used to be a *tail* on a window that also
stood through the claim being spoken, so a 4s claim gave the viewer ~9s
of card. Now it is the card's whole life, and 5s would have quietly cut
the time to read a sentence and press a thumb roughly in half — a
regression nobody asked for, hiding inside a change about when it
appears. Eight keeps the time on screen about where it was. Claims in
the test debate land 5–9s apart at their closest, so a couple will
overlap, which is what the stack is for.

The scrubber markers are untouched: they read the claim's own timing, so
jumping still lands where the claim was *said* rather than where its
card appeared.

Verified on the live debate against a claim spoken 16.7s–21.9s: at 19s
the corner is empty where it used to hold the card, and at 24s the card
is up while the subtitle has moved on to his next sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w they are placed (GEO-2909)

A hash sitting at the claim's start marked a moment the card would not
appear at for several more seconds, so the two ways the timeline talks
about one claim disagreed. Jumping to a hash now lands where its card
does.

**And the hashes were in the wrong place regardless.** Their fractions
were scaled by the *latest claim's end* while the track they are drawn on
is sized by the debate's own timeline. Those only agree when the last
claim runs to the final second — which is true of the hand-published test
debate and false elsewhere. Measured across eight live debates the gap
reached 11%: about 20 seconds out on a 210-second debate, on a control
whose whole job is to land you somewhere exact. `useDebateClaimTicker`
now takes the timeline the player already has.

Verified against the four hand-published claims: the hashes land at
21.9s, 31.4s, 47.8s and 56.4s, which are their published end offsets to
the millisecond.

Also, from Preston's screenshot of the hovered state:

**The open list stops at 60% of the tile.** It was filling the whole
thing and climbing to the debater's chin — more of their face than it
needs, and further than the edge fade could dissolve. 60% is roughly the
168px of a 291px tile the frame gives it, and the fade ramp grows to
match so the top dissolves rather than being cut.

The cap belongs on the positioned host, not on the list. A percentage
max-height resolves against the parent's height, and the list's parent is
content-sized — so capping the list made it a fraction of a box it had
itself defined, and the whole stack ended up pinned to the top of the
tile. On the host the percentage is of the tile, which the aspect ratio
makes definite, and `bottom-3` keeps it anchored. Caught in the browser;
the numbers now read 231px of a 385px tile, bottom-anchored.

**The cards are glass.** `backdrop-blur-md`, on the cards and the chip.
The Figma MCP would not answer for that node this time, so the value is
picked to match the frame rather than read off it — one token to change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DebateTicker.answered` had no consumer. The end-of-debate card reads
`answers` — the map of which way each claim went — and nothing ever
wanted the bare set of ids. Missed by the review's unused-export audit,
which checks exported symbols rather than the fields of an exported
type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…909)

Preston's call: keep the timing work and the live claim layer here, and
judge the card that asks for positions after the video separately.

Removes `debate-scorecard.tsx` and its suite, the overlay the player drew
when playback ended, and the three `DebateTicker` fields nothing else
read — `claims` in spoken order, `answers`, and `speakerByClaimId`.
`onAnswered` stays: the live cards report a taken side through it too.

Nothing else changes. Ending a debate now leaves the top-left control
showing Replay, which is what it already did — the card was drawn over
the top of a player that worked without it.

Reverted wholesale on `preston/geo-2909-end-of-debate-scorecard`, which
stacks on this branch, so the two can be reviewed apart and recombined
with a single revert if that turns out to be easier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(GEO-2909)

Split out of #2439 so it can be judged on its own. Stacked on that
branch — review it first; the diff here is only the end card.

Once the video has stopped and there is nothing left to interrupt, the
player dims and offers the claims one at a time: the same pills the
explore feed, the hub and the claims panel use, a Skip beside the speaker
and timecode, and Watch again in the header.

A taken side stays on screen for a beat before the card advances.
Advancing the instant the response landed swapped the claim out from
under the press, so the pill never showed as taken and there was no way
to tell the tap had registered. The response is published either way;
only the card waits.

**It ends on the debate's own question.** Having gone through every claim
the viewer has effectively assembled their answer to "who won", so the
card tallies which debater they agreed with more often — counted against
the claims they actually answered, not everything that debater said — and
offers the winner vote right there, per debater, on a row carrying their
avatar and name so it is obvious which button is whose. Same hook as the
tiles, so the two cannot disagree.

That tally is rate-based rather than count-based, which is a bug this
already had: agreeing with 7 of 7 and 6 of 6 is agreeing with both, and
ranking by raw count called it a lean towards whoever happened to make
one more claim — reporting who talked more.

Restores the three `DebateTicker` fields only this reads: `claims` in
spoken order, `answers`, and `speakerByClaimId`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
geogenesis Ready Ready Preview Sep 18, 2026 4:34pm UTC

Request Review

@jwalkingjew
jwalkingjew force-pushed the preston/geo-2909-time-align-debate-claims-to-the-video-so-they-can-be branch 2 times, most recently from 04f6989 to 7002199 Compare September 20, 2026 01:12
@jwalkingjew
jwalkingjew changed the base branch from preston/geo-2909-time-align-debate-claims-to-the-video-so-they-can-be to master September 21, 2026 18:52

This branch was successfully deployed

1 active deployment
Preview — a0716dce Deployed Sep 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant