Repository navigation
feat(debates): ask for positions on every claim once the debate ends (GEO-2909) - #2459
Draft
jwalkingjew wants to merge 30 commits into
Draft
jwalkingjew wants to merge 30 commits into
jwalkingjew wants to merge 30 commits into
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
jwalkingjew
force-pushed
the
preston/geo-2909-time-align-debate-claims-to-the-video-so-they-can-be
branch
2 times, most recently
from
September 20, 2026 01:12
04f6989 to
7002199
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tspins 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
DebateTickerfields that only this reads:claimsin spoken order,answers, andspeakerByClaimId.onAnsweredis 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 revertif that turns out to be easier than reviewing them apart.Testing
Full
apps/websuite green on this branch: 484 files, 5714 tests (483 / 5708 on #2439 without it).tsc --noEmitclean apart from the four files already failing onmaster.