Skip to content

feat(debates): unify the explore debate card with the full screen experience (GEO-2912, GEO-2879) - #2440

Merged
jwalkingjew merged 13 commits into
masterfrom
preston/geo-2912-unify-debate-card-with-full-screen
Sep 21, 2026
Merged

jwalkingjew merged 13 commits into
masterfrom
preston/geo-2912-unify-debate-card-with-full-screen

Conversation

@jwalkingjew

@jwalkingjew jwalkingjew commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Closes GEO-2912. Rebased onto master, which shipped GEO-2879 separately in #2458 — see Merged with master below.

The gap

The videos were already one component. DebateFeedPlayer renders both surfaces, so the debater tiles, the name and Agree/Disagree chips, and the winner share pill have always matched.

Nothing around them did. Full screen renders a header with "Join a debate" in its corner, the claim as the title, the videos, and DebateInteractionBar beneath or beside them. The card rendered a "View all" link, the Debate entity's name as the title, the videos, and the generic EntityRowActions underneath — inline vote arrows plus two grey icon buttons drawn only for this one card.

The change

The card now renders the same claim title over the same videos over the same bar, in the same arrangement.

Element Before Now
Participant video + layout shared shared
Name + position labels shared shared
Winner % shared shared
Header CTA "View all" → the space's debates list a fullscreen expand control opening this debate, aligned to the videos
Bar generic explore row, full card width shared bar, spanning the videos
Column width flat 480px sized to the viewport: 320–560px
Claim title Debate entity name, navigates the claim, opens it in the side panel (master's ExploreCardTitle, #2458)
Vote control + score inline arrows + responder avatars shared vote pill with score
Comment count grey icon + count shared control
Claims count grey icon + count shared control
Share grey icon + "Share" shared control

Merged with master

Master moved 60+ commits under this branch and four files conflicted. Two resolutions are worth calling out, because in both cases master's behaviour won over this branch's.

The claim title is master's, not this branch's. #2458 landed GEO-2879 as ExploreCardTitle while this was in review, and its version is better: it reads the claim from the Debate's own Claims relation, which the explore feed resolves before the card paints. This branch read geo-chat's debate.claim, which is behind a viewport gate that lands after — so the title swapped from the entity name to the claim under a reader mid-scroll, the exact failure master's version exists to avoid. Master's also heads the non-watchable fallback card, where this branch's only headed the video card. DebateCardTitle is deleted.

What carried over is the two-line clamp, because this card's height budget is calculated against a two-line title and a third line is 23px the viewport was not promised. That went onto ExploreCardTitle as an optional className, passed only by the debate card — it is the only card that sizes fixed-aspect media from what the title leaves over.

Claims and Share now stand down with the player, not just with the debate. Master made the media window non-sticky, so leaving it unmounts the videos and releases them (GEO-2963), and its test asserts the Claims control goes with them. Hoisting the interaction state to the card root for the shared bar had quietly undone that — the transcript-claims query would have stayed subscribed for every off-screen row. Both controls now gate on mediaMounted (resolved and in the window), while votes and the comment count, which ask geo-chat nothing, stay for the whole row's life.

The rest: the bar takes master's Warning claims icon (#2462), DebateCardVideos keeps master's {debate, active} signature and playbackAllowed veto, the feed takes master's useLineClampOverflow refactor, DebateCardExtras is gone (the shared bar replaced the grey icon buttons it held), and the test file is master's with this branch's join-button and shared-bar cases re-applied.

The full-screen control

The corner is a bare expand glyph linking to this debate at full size. It replaced "Join a debate", which had replaced "View all".

This closes a gap GEO-2879 named in its own notes: once the heading became the claim, nothing on the card pointed at the debate any more, and that ticket asked where the path to the full-screen debate would go.

The Debate entity's own page is that path — not debatePath, which is the live recording room. DebateEntityView renders the entity page as the /debates feed anchored to this debate, i.e. the full-screen experience with this debate on top.

Styling is shared, not copied: both this and a data block's header render FullscreenLink, so the same offer looks the same in both places. It stays an anchor with a real href, so modifier-click opens a tab (GEO-2701).

An earlier revision of this PR extracted JoinDebateButton to share that button between the card and the feed. That is reverted — with the card no longer joining, it served one caller, which is a refactor rather than shared code. debate-feed.tsx is back to master's version byte for byte and the feed's seven "Join a debate" cases are master's again, passing untouched.

The bar

Beneath the videos and the same width as them, inside the capped column. Full screen carries this bar in a rail down the media's right at desktop widths and moves it below at narrow ones; a card is short and wide where full screen is tall, so it takes the horizontal arrangement at every width. Same component, same controls, same counts — only the axis differs, and that is a difference full screen already makes between its own breakpoints.

Width

The column was a flat 480px, which on a tall display left the card smaller than it needed to be and on a short one made it taller than the viewport. Width is what sets height — the tiles are fixed-aspect — so widening the card and fitting it vertically are the same problem, and the answer is to derive the width from the height available.

Measured: the media is 1.2042 × width + 8, and the rest of the card is a flat 163px (32px of py-4, a 28px meta row, a two-line 46px title, a 32px bar with its margin, 24px of column gaps, the 1px rule). A card is therefore 1.2042 × width + 171 tall, and keeping that under the viewport less the 44px app header gives width ≤ 0.83 × dvh − 178px.

Hence clamp(320px, calc(83dvh - 178px), 560px) — the same trick, and the same reason, as --debate-feed-column-width on the full-screen feed. Verified at 1440px wide against the real 44px header:

viewport column card budget fits
1200 560px 822px 1156px ✓
1000 560px 822px 956px ✓
900 560px 822px 856px ✓
800 486px 756px 756px ✓ (exact)
700 403px 656px 656px ✓ (exact)
600 320px 556px 556px ✓ (exact)

So it is wider than before wherever there is room (560px from a 900px viewport up, against the old 480px) and narrower where there isn't. The 320px floor gives the promise up below roughly a 600px viewport, where honouring it would mean a video too small to read a face in.

The claim title is clamped to two lines for this to hold — a third line is 23px the viewport was not promised — which is also what the full-screen header does to the same claim. No title tooltip with it: clamping is not truncation, most claims fit in two lines, and a tooltip repeating visible text is worse than none. Telling the difference needs a ResizeObserver per card to reveal what the heading's own link already opens.

Two seams added to the shared bar

Both about where a control leads, not what it looks like:

  • commentsPanelOpen marks the comment control as an opener of the app's global comments panel. Explore routes comments there, as every other explore card does, and that panel recognises its openers by data-entity-comments-opener. Without it, pressing the control while the panel is open reads as an outside click and dismisses the panel instead of switching it to this debate. Left undefined by the full-screen feed, which opens a panel it controls itself.
  • Claims and Share are now optional, so the card can render the bar before its geo-chat lookups land. Votes and comments need no debate; those two do. A footer that appeared only once the debate resolved would shift the card under the reader.

Counts stay the card's own — the comment count the explore feed already resolved (so a page of debates doesn't fetch a thread apiece) and the shared transcript-claims query the Claims panel itself reads from.

explore-claims-icon.tsx and explore-share-icon.tsx existed only for the two buttons this replaces, and are deleted with them.

Still not unified, deliberately

The meta row. Full screen shows space · topics + "Join a debate"; the card shows space + Join chip + "Debate" tag + timestamp + the expand control. That row is shared explore-card chrome, and a debate card that disagreed with the cards above and below it would trade one inconsistency for another.

A call worth confirming

The ticket says full screen is the reference "rather than meeting in the middle", and this follows that. The cost: on Explore the debate card's actions now read as bordered pills while neighbouring cards keep the plain arrow row. In practice it sits fine — claim cards have their own Agree/Disagree treatment anyway — but it is a real trade of explore-internal consistency for cross-surface consistency, and it is yours to confirm.

Verification

  • Full build green. 6835 tests across 571 files; tsc --noEmit and eslint clean.
  • The full-screen feed's own suite is master's, unchanged, and passes — which is what says reverting the extraction cost it nothing.
  • Exercised in the running app against testnet geo-chat: the heading renders the claim and opens it in the side panel with the URL still on /explore; the bar sits beneath the videos spanning their width, with one vote control and one Share per card; the expand control sits flush with the videos' right edge and lands on the same debate full screen with playback continuing; Claims opens the shared panel with the right count; comments open the global panel and a second press keeps it open.
  • The height budget is measured rather than asserted: across widths 390–1440 × heights 600–1000, including the meta row's widest wrapped state, every card fits its viewport.

… full screen (GEO-2912)

A debate on Explore and the same debate full screen read as two features. The videos
were already one component -- `DebateFeedPlayer` -- so the debater tiles, position
chips and winner share have always matched. Everything under them did not: full
screen has the bordered vote pill carrying the score, and comment/claims/share
controls with counts, while the card had the generic explore row -- inline vote
arrows plus two grey icon buttons drawn only for this card.

So give the card the same `DebateInteractionBar`, in the horizontal orientation the
full-screen feed already uses at narrow widths. Sharing the component rather than
restyling the row toward it is the point: a restyle drifts again the next time either
surface moves.

The bar grows two optional seams for the card, both about where a control leads
rather than what it looks like:

- `commentsPanelOpen` marks the comment control as an opener of the app's global
  comments panel. Explore routes comments there, as every other card does, and that
  panel recognises its openers by `data-entity-comments-opener` -- without it a press
  while the panel is open reads as an outside click and dismisses it instead of
  switching it to this debate.
- Claims and Share are now optional, so the card can render the bar before its
  geo-chat lookups land. Votes and comments need no debate; those two do. A footer
  that appeared late would shift the card under the reader.

Counts stay the card's own: the comment count the explore feed already resolved, so
a page of debates doesn't fetch a thread apiece, and the shared transcript-claims
query the Claims panel reads from.

`explore-claims-icon` and `explore-share-icon` existed only for the two buttons this
replaces, and go with them.

Not touched: the card title. Full screen shows the claim and the card shows the
Debate entity's name ("A vs. B on <claim>"), but that title and its click target are
GEO-2879's, which contradicts GEO-2794 and its open PR. Unifying it here would pick a
winner in someone else's argument.
@vercel

vercel Bot commented Sep 15, 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 21, 2026 9:36pm UTC

Request Review

…ar as a rail (GEO-2912, GEO-2879)

Two things the card still did differently from the same debate full screen.

The interaction bar sat under the videos rather than down their right. Full screen
puts it in a rail beside them and only drops to a row beneath at its own narrow
widths, so the card does the same, against its own width rather than the window's:
the media caps at 480px and the rail is 36px with a 12px gutter, so under ~540px of
card every pixel the rail takes comes out of a video whose overlaid name, position
chip and Winner pill are already competing for the bottom edge. `@container`, not a
media query, because this card is drawn in the feed, in a side panel and in a data
block, at widths a viewport measurement says nothing about.

Both orientations mount and the container query hides one, which is how full screen
does it too. That moves the interaction state up into the card: both bars have to
read the same open flags, and the dialogs must exist once -- a `hidden` wrapper still
mounts its children, so a share dialog owned by the losing bar would portal itself on
screen anyway.

The title was the Debate entity's name -- "<A> vs. <B> on <claim>", the claim with a
preamble -- where full screen titles the debate with the claim alone. Now the card
does too, linked to the claim entity in the claim's own space, opening it in the side
panel on Explore (GEO-2879). Until the geo-chat lookup lands there is no claim to
show, and the entity name stands in rather than the title arriving a beat late.

That reaches the panel by going through `ExploreCardEntityLink`, which refuses it to
debates because a full-screen video experience is a poor fit for one (GEO-2794). This
is the claim, which a panel serves well -- it is the whole reason GEO-2879 can have
what GEO-2794 refused -- so the debate exception is untouched and still holds for the
fallback card, whose title is the debate.

Ids are normalized on the way: geo-chat returns UUIDs where the graph, and every
explore route and panel target, spells them as plain hex.

The test's `PrefetchLink` stand-in forwarded only `href`, so it would have let a title
with broken click handling pass. It forwards what it is given now.
@jwalkingjew jwalkingjew changed the title feat(debates): share one interaction bar between the explore card and full screen (GEO-2912) feat(debates): unify the explore debate card with the full screen experience (GEO-2912, GEO-2879) Sep 15, 2026
…e videos (GEO-2912)

The card's top-right held "View all", a link into this space's debates list. But the
card *is* a debate you can watch, so the CTA worth that corner is the one full screen
puts there: the one that puts you in a debate rather than one that lists more of them.

Extracted as `JoinDebateButton` and shared with the full-screen feed rather than
written twice. What this control decides is worth more than what it looks like, and
every branch of it was a bug once: do nothing until Privy has restored the session
(gating on the smart account sent signed-in viewers back through a login that wiped
their half-finished onboarding); send a signed-out viewer to sign in and then finish
what they pressed, rather than returning them to press it again; close the hub on a
second press, or it is a one-way door. A second copy is a second chance to get one of
those wrong.

The feed keeps the two things that are its own, as callbacks: making the pressed
debate active -- its bar is reachable from 0% visibility while activation needs 60%,
so mid-scroll the hub would open from the debate being scrolled away from -- and
closing its in-flow panel so the hub doesn't stack over one. Both are no-ops for the
card, which has neither.

Aligning the button with the videos is why the meta row and title now sit in the
capped column with the media instead of spanning the card. That is the arrangement
full screen already has -- header above the media, bar beside it -- so the alignment
falls out of the structure rather than from repeating a width. The title consequently
wraps at the media width rather than the card's, which is also what full screen does.
…deos (GEO-2912)

Reverts the rail. A card is short and wide where full screen is tall, so the bar
reads better under the videos than down their side, and it takes the horizontal
arrangement at every width rather than switching on one.

What that removes, all of it scaffolding the rail needed and nothing else did: the
`debate-card-narrow` container-query variant, the `@container` on the article it
asked, the second `DebateInteractionBar` that the query hid, and the two test ids the
suite used to tell one bar from the other. One bar in the DOM again, so the tests
query it directly.

The bar keeps what it gained on the way: it is still the shared component, still
carrying the vote pill, score and counts the full-screen feed carries, and it now
sits inside the capped column, so it spans the videos rather than the whole card.
Only the axis differs between the two surfaces, which is a difference full screen
already makes between its own breakpoints.

The card keeps the interaction state that moved up to it for the rail's sake. The
reason is no longer that two bars have to share flags -- the bar is shared with the
full-screen feed and stays presentational, so what a control opens is the card's to
own and to render once. Comment says so now.
…ched in (GEO-2912)

The column was a flat 480px, which on a tall display left the card smaller than it
needed to be and on a short one made it taller than the viewport. Width is what sets
height here — the tiles are fixed-aspect — so widening the card and fitting it
vertically are the same problem, and the answer is to derive the width from the height
available.

Measured: the media is `1.2042 × width + 8`, and the rest of the card is a flat 163px
(32px of `py-4`, a 28px meta row, a two-line 46px title, a 32px bar with its margin,
24px of column gaps, the 1px rule). So a card is `1.2042 × width + 171` tall, and
keeping that under the viewport less the 44px app header gives
`width ≤ 0.83 × dvh − 178px`.

Hence `clamp(320px, calc(83dvh - 178px), 560px)`: wider than before wherever there is
room (560px from a 900px viewport up, against the old 480px), narrower where there
isn't, and floored at 320px below roughly a 600px viewport, where honouring the
promise would mean a video too small to read a face in. Same trick and same reason as
`--debate-feed-column-width` on the full-screen feed.

Verified at 1440px wide against the real 44px header: 1200/1000/900 → 560px column,
822px card; 800 → 486px, 756px; 700 → 403px, 656px; 600 → 320px, 556px. Every one
inside its budget, exact from 800 down where the formula binds.

The title is clamped to two lines for this to hold — a third is 23px the viewport was
not promised — which is also what the full-screen header does to the same claim. It
keeps a `title` attribute so a cut claim is still readable.
…d re-render reaches (GEO-2912)

Review pass over the branch. Three of the findings were real.

`DebateCardVideos` is memoized. The card root subscribes to the global
comments-panel atom -- it has to, to tell the bar whether the panel is open on this
debate -- where the leaf button it replaced held that subscription before, so opening
comments anywhere re-rendered every debate card down through the player and its
playback hooks. `debate` is a stable react-query object and the flags are booleans, so
the memo skips the subtree for a flag it does not read. Playback was never interrupted
by this -- a re-render keeps the <video> element's identity -- it was just work
nobody asked for, once per card.

Two comments were wrong, which matters more than it sounds: the review chased both.

The claim-link comment claimed geo-chat "hands these back as UUIDs". It does not --
it takes either spelling on the way in and answers in plain hex, verified against
`chat-api-testnet`, so `uuidToHex` there is a no-op. The normalization stays, because
what it guards is the hex-keyed side -- routes, the sync store `PrefetchLink`
prefetches from, the side-panel target -- and it is the same guard
`useDebateTranscriptClaims` puts at the same boundary. But it now says so.

The column-width doc worked through numbers (569px at 900dvh, 651px at 1000dvh) that
its own 560px cap makes unreachable; the cap binds from about 890px up. It now quotes
the widths that actually occur.

Two test fixtures were quietly unfalsifiable. `debate.id` and `claim_entity_id` were
hyphenated, describing a response shape geo-chat never sends -- which is what invited
a fix for an id mismatch that isn't there. And the comment and claims counts were both
3, so the assertion tying each count to its own source would have passed with the two
wirings swapped. Now hex, and 3 against 18.

Also names `opensGlobalCommentsPanel` once in the bar rather than deriving it inline
in each orientation.

Verified: full build green, 4943 tests across 449 files pass, eslint clean on the
changed files (bar the two `_`-prefixed unused destructures the repo's own test mocks
already carry).
Master moved 60+ commits under this branch, four files conflicted, and one of those
commits had already shipped half of what this branch was doing.

**The claim title is master's now, not this branch's.** #2458 landed GEO-2879 as
`ExploreCardTitle`, and its version is better: it reads the claim from the Debate's own
`Claims` relation, which the explore feed resolves before the card paints, where this
branch read geo-chat's `debate.claim` behind a viewport gate that lands after. So this
branch's title swapped from the entity name to the claim under a reader mid-scroll --
the exact failure master's version exists to avoid -- and it only headed the video
card, where master's also heads the non-watchable fallback. `DebateCardTitle` is
deleted; the card calls `ExploreCardTitle`.

What it keeps from here is the two-line clamp, because this card's height budget is
calculated against a two-line title and a third line is 23px the viewport was not
promised. That went to `ExploreCardTitle` as an optional `className` -- only the debate
card passes it, since only the debate card sizes fixed-aspect media from what the title
leaves over.

**Claims and Share now stand down with the player, not just with the debate.** Master
made the media window non-sticky so leaving it unmounts the videos and releases them
(GEO-2963), and its test asserts the Claims control goes with them. Hoisting the
interaction state to the card root for the shared bar had quietly undone that: the
transcript-claims query would have stayed subscribed for every off-screen row. Both now
gate on `mediaMounted` -- resolved *and* in the window -- while votes and the comment
count, which ask geo-chat nothing, stay for the whole row's life.

The rest of the resolution:

- The bar takes master's `Warning` claims icon (#2462 unified it) with this branch's
  optional Claims/Share and `commentsPanelOpen`.
- `DebateCardVideos` keeps master's `{debate, active}` signature and its
  `playbackAllowed` veto, wrapped in the `React.memo` the review added.
- The feed takes master's `useLineClampOverflow` refactor alongside `JoinDebateButton`.
- `DebateCardExtras` is gone -- the shared bar replaced the grey icon buttons it held,
  which is also why the `ExploreShareIcon` master still imported can stay deleted.
- The test file is master's, with this branch's join-button and shared-bar cases
  re-applied on top; master's own `claim heading` block covers what this branch's title
  tests did. `@geogenesis/auth` needed rebuilding for master's new exports.

Verified: full build green, 6786 tests across 561 files pass, `tsc --noEmit` now
completely clean -- master's #2488 fixed the three test files that used to fail it.
…912)

Post-merge review. All four are regressions this branch introduced by replacing
`EntityRowActions`/`EntityCommentsButton` with the shared bar, not pre-existing.

**An open claims panel no longer vanishes, or reopens itself.** The open flags moved
up to the card for the shared bar, but the panel stayed gated on `mediaMounted` with
the controls. Both panels are fixed overlays over a feed that still scrolls, so
scrolling ~800px past the card tore the panel away mid-read — and since the flag now
lives somewhere that did not unmount with it, scrolling back reopened it unasked. The
overlays gate on `readyDebate`; only the controls that open them stand down with the
window. `DebateClaimsPanel` loads its own claims, so it does not go empty when the
card's count stands down beside it. Test covers it, and fails without the fix.

**The comment count is live again.** Master had put `useCommentCount` behind
`EntityCommentsButton` so posting from the panel the pill opens updates the pill;
dropping that button dropped it, and the number sat one behind its own list until a
reload. The stated reason for passing the server count straight through — not fetching
a thread per card — was wrong about that hook: it subscribes to the comments cache
without enabling a query, so it costs no request. Its own docs even name this surface.

**The counts are said, not just shown.** `aria-label` replaces a control's visible
text for a screen reader, and the visible text on Comments and Claims *is* the number,
so labelling them "Comments" and "Claims" dropped the one thing they report.
`EntityCommentsButton` announced "Comments (3)"; the bar does now too, on both
surfaces. Queries in both suites match on the prefix.

**The heading tooltip stays on the card that needs it.** Adding `title` to
`ExploreCardTitle` for the clamped debate heading put a native tooltip on every explore
card title, most of which are not cut and so were describing themselves. Replaced the
free-form `className` with one `clamped` flag: the clamp and the tooltip are one
decision, and a heading that cannot be cut must not grow a tooltip.

Verified: full build green, 6787 tests across 561 files, tsc and eslint clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The responsive height calculation does not account for metadata wrapping at its minimum width.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Unifies Explore debate cards with the full-screen debate experience.

Changes:

  • Shares debate interaction and join controls across both surfaces.
  • Adds responsive sizing and clamped claim titles.
  • Expands tests for interactions, authentication, and viewport behavior.
File Description
apps/​web/​partials/​explore/​explore-share-icon.tsx Removes obsolete share icon.
apps/​web/​partials/​explore/​explore-card-title.tsx Adds optional title clamping.
apps/​web/​partials/​explore/​debate-explore-feed-card.tsx Adopts shared controls and responsive layout.
apps/​web/​partials/​explore/​debate-explore-feed-card.test.tsx Tests the unified card behavior.
apps/​web/​core/​debates/​browse/​join-debate-button.tsx Introduces the shared join control.
apps/​web/​core/​debates/​browse/​debate-interaction-bar.tsx Supports optional actions and global comments.
apps/​web/​core/​debates/​browse/​debate-feed.tsx Uses the shared join control.
apps/​web/​core/​debates/​browse/​debate-feed.test.tsx Updates accessible-label assertions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/web/partials/explore/debate-explore-feed-card.tsx
…d control (GEO-2912)

The card's corner is a fullscreen expand icon now, linking to this debate at full
size, where it held "Join a debate" and before that "View all".

This closes a gap GEO-2879 opened and named in its own notes: once the heading became
the claim, nothing on the card pointed at the debate any more, and that ticket asked
where the path to the full-screen debate would go. Here — as a control that says
"bigger" rather than one competing with the claim for the heading.

The Debate entity's own page is that path, not `debatePath`: `debatePath` is the live
recording room, while `DebateEntityView` renders the entity page as the `/debates`
feed anchored to this debate, which is the full-screen experience with this debate on
top. So this is the entity link the heading used to be, moved to its own control.

An anchor with a real href, so cmd-click still opens a tab (GEO-2701), carrying the
`Fullscreen` icon and the chrome the entity side panel's own "open this full page"
control uses. That icon existed and had no callers; this is not the place to invent a
second answer to the same question.

**`JoinDebateButton` is reverted with it.** It was extracted so the card and the
full-screen feed could not answer a press differently -- and with the card no longer
asking, it served one caller, which is a refactor rather than shared code. So
`debate-feed.tsx` goes back to master's version byte for byte and the new file is
deleted, which takes ~80 lines of diff out of this PR for no change in behaviour. The
feed's seven "Join a debate" cases are master's again and pass untouched. Easy to
reinstate if a second surface ever needs it.

The card suite loses the hub, Privy and geo-chat-auth mocks it only needed for that
button, and gains cases for where the expand control points and that it is offered
before the geo-chat lookups land -- the route resolves the debate itself, so it needs
none of them.

Verified in the app: the icon lands on the same debate full screen with playback
continuing, its right edge still flush with the videos at 940px, and the card still
fits the viewport (814px against 856px). Full build green, 6784 tests across 561
files, tsc and eslint clean.
…e card (GEO-2912)

Drops the circular button chrome from the card's full-screen control: it is the plain
grey glyph a data block's header uses, which is what the reader has already learned
that offer looks like.

Shared rather than restyled. `data-block-expand-control` had the treatment in a local
class string, and the honest way to make the card match is not to copy that string —
the styling *is* the whole contract here, so a copy is a copy that drifts. Both now
render `FullscreenLink`: 24px, transparent, `grey-04`, no border, with the data
block's own hover, focus ring and inert-while-disabled behaviour. Same reasoning, and
the same "one definition so these cannot drift apart again", as `vote-button-styles`.

It lives in the design system because the two callers are peers in different trees —
a data block header in `partials/blocks/table`, an explore card in `partials/explore`
— and neither should be importing chrome out of the other.

An anchor still, never a button, so modifier-click opens a tab and "copy link address"
works (GEO-2701). `DataBlockExpandControl` keeps its own `onClick`/`IconButton` branch:
some surfaces expand in place rather than navigating, and that is a button's job.

Verified in the app: bare glyph, right edge still flush with the videos at 940px, card
still fits the viewport (810px against 856px), and the icon still lands on the same
debate full screen. Full build green, 6784 tests across 561 files, tsc and eslint
clean — including the data block's own 52 files.
…e smaller things (GEO-2912)

Third review pass, on the branch merged up to master (5 more commits, no conflicts).

**The vote pill can find a debate's own votes again.** The shared bar hardcoded
`responseKind="curation"`, which is not just a kind: naming one skips
`EntityVoteButtons`' entity lookup, and with it `resolveEntitySpaceId` — the thing
that diverts counts and votes to the space an entity actually lives in when a surface
is listing it from somewhere else (GEO-2660). The card used to infer, so it had that.
It is reachable: `use-block-explore-feed-item` sets `item.spaceId` from the row's name
cell, and routes the card on unscoped `cardTypes`, so a data block row carrying a
debate from another space renders a debate card whose votes were being read against
the listing space — where there are none. The bar takes `responseKind` now, defaulting
to the fixed kind the full-screen feed wants (it only ever shows a space its own
debates) and set to `'infer'` by the card. A debate infers to `curation` either way,
so this changes which space is read, not how the control looks.

**The comments control no longer claims to open a dialog.** Feeding `commentsPanelOpen`
into `expanded` also emitted `aria-haspopup="dialog"`, and the app's comments panel is
an in-flow panel with no dialog role and no focus trap — `EntityCommentsButton`
announced only `aria-expanded`. Split into its own `open` prop so `aria-haspopup`
stays with the controls that really do open one, which Share still does.

**The clamped heading drops its tooltip.** `title` was applied whenever clamping was
on, not when the text was actually cut, so short claims got a tooltip repeating what
was already on screen — contradicting the prop's own doc. Telling the difference means
measuring, as the full-screen header does with `useLineClampOverflow`, and that is a
ResizeObserver per card in an infinite feed to reveal what the heading's own link
already opens. So: clamp, no tooltip.

**`FullscreenLink`'s `disabled` is actually disabled.** `pointer-events-none` alone
only stops the mouse — the anchor stayed tabbable and Enter still followed it, so a
keyboard could reach what a pointer could not. It leaves the tab order and says
`aria-disabled` now, which is what that class was always meant to mean, and what the
doc I wrote for it already claimed.

Verified: full build green, 6833 tests across 570 files, tsc and eslint clean. New
cases cover the inferred response kind and the dialog announcements on both controls.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The implementation omits the specified shared join action and has unresolved disabled-link and clamped-title behavior.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 4 Medium severity

Open (4)

Comment thread apps/web/design-system/fullscreen-link.tsx
Comment thread apps/web/partials/explore/debate-explore-feed-card.tsx
Comment thread apps/web/partials/explore/explore-card-title.tsx
…ct the card's height note (GEO-2912)

Copilot review. Two of its four findings were worth acting on; the reply on each thread
says which and why.

**A disabled `FullscreenLink` now refuses the navigation.** Last round I added
`tabIndex={-1}` and `aria-disabled` so the doc's "inert" claim would be true. It still
was not: `pointer-events-none` stops a mouse, `tabIndex` stops Tab, `aria-disabled`
only says so, and none of them stop an activation arriving another way — assistive
tech, a programmatic `focus()` and Enter, a synthesized click. The `href` has to stay
for the enabled case, so the click is cancelled instead. It is the only control in
this PR that renders while inert; the other `pointer-events-none` in the diff are
decorative overlays.

**The height note said 28px for a meta row that measures 24px**, and reasoned from a
per-pixel decomposition precise enough to be wrong again the next time a padding
moves. It now states what is checkable: the media formula (predicted 682.3px against
682px measured on a 560px column), the ~170px the clamp reserves, and the 128–151px
the chrome actually spans — so the slack is named rather than implied.

That also answers the finding behind it. The meta row is `flex-wrap` and its widest
state does take a second line: "Membership pending" beside a long space name measures
41px against the usual 24px. But it only reaches that at the 320px floor, which is
where the clamp has already stopped binding, and even inside the binding range the
worst case is 168px against 170px reserved. Measured across widths 390–1440 and
heights 600–1000 with that state forced: nothing overflows. So the row keeps wrapping
rather than growing a truncation strategy, and the note now explains why that is safe
instead of assuming a fixed height.

Verified: full build green, 6835 tests across 571 files, tsc and eslint clean. The new
`fullscreen-link` suite fails without the click fix — checked by removing it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The shared components, lifecycle gates, accessibility behavior, and regression coverage are consistent and complete.

Review effort: Balanced
Findings: None

Resolved since last review (4)

@jwalkingjew
jwalkingjew marked this pull request as ready for review September 21, 2026 21:43
@jwalkingjew
jwalkingjew merged commit 9a97f03 into master Sep 21, 2026
14 checks passed
@jwalkingjew
jwalkingjew deleted the preston/geo-2912-unify-debate-card-with-full-screen branch September 21, 2026 21:43

This branch was successfully deployed

1 active deployment
Preview — 4ea0c633 Deployed Sep 21, 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.

2 participants